Merge branch 'master' into fix/agc-acceptance-followup
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Failing after 19s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Failing after 19s
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Failing after 19s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Failing after 19s
Project CI / Backend tests (pull_request) Failing after 18s
Project CI / AI game creator shell Rust smoke (pull_request) Failing after 18s
Project CI / Native shell tests (pull_request) Failing after 18s
Project CI / AI game creator shell Rust crates (pull_request) Failing after 18s
Project CI / Frontend tests (pull_request) Failing after 7s
Project CI / AI game creator shell web tests (pull_request) Failing after 12s
Project CI / Repository checks (pull_request) Failing after 12s

This commit is contained in:
2026-09-21 00:38:08 +08:00
27 changed files with 1179 additions and 3580 deletions
@@ -334,6 +334,7 @@ fn begin_design_turn(session: &mut DesignSession, id: &str) {
pending: true,
request_index: 0,
attempt: 0,
model_selection: None,
});
session.last_error = None;
session.updated_at = unix_timestamp();
@@ -444,6 +445,45 @@ fn prepare_design_decision(
Ok(approved)
}
fn select_design_turn_model(
session: &mut DesignSession,
config: &GameCreatorAppConfig,
) -> Result<(), String> {
let turn = session.turn.as_mut().ok_or("缺少当前回合")?;
session.model_id = if config.selected_model_id.trim().is_empty() {
config.llm.model.clone()
} else {
config.selected_model_id.clone()
};
turn.model_selection = Some(DesignModelSelection {
model: session.model_id.clone(),
reasoning_effort: config.llm.reasoning_effort.clone(),
});
Ok(())
}
fn resolve_design_turn_llm_config(
session: &mut DesignSession,
config: &GameCreatorAppConfig,
) -> Result<GameCreatorLlmConfig, String> {
let turn = session.turn.as_mut().ok_or("缺少当前回合")?;
// 旧活动回合恢复时保留已知模型;缺失的推理档只能从当前配置补齐一次。
let selection = turn
.model_selection
.get_or_insert_with(|| DesignModelSelection {
model: if session.model_id.trim().is_empty() {
config.llm.model.clone()
} else {
session.model_id.clone()
},
reasoning_effort: config.llm.reasoning_effort.clone(),
});
let mut llm = resolve_game_creator_llm_config_for_agent(config, "design-agent");
llm.model = selection.model.clone();
llm.reasoning_effort = selection.reasoning_effort.clone();
Ok(llm)
}
fn checkpoint_design(root: &Path, session: &DesignSession) -> Result<(), String> {
let _write =
acquire_game_creator_agent_runtime_project_write_lock_with_wait(root, "design.session")?;
@@ -696,10 +736,7 @@ async fn request_design_provider(
return request_scripted_design_provider(root, session, emit).await;
}
let config = load_game_creator_app_config()?;
let mut llm = resolve_game_creator_llm_config_for_agent(&config, "design-agent");
if !session.model_id.trim().is_empty() {
llm.model = session.model_id.clone();
}
let mut llm = resolve_design_turn_llm_config(session, &config)?;
// 此循环统一处理流中断与 HTTP 瞬态错误,避免与传输重试相乘。
let max_retries = llm.max_retries;
llm.max_retries = 0;
@@ -1003,6 +1040,14 @@ async fn finish_design_command(
run: bool,
mut emit: impl FnMut(DesignEvent) + Send,
) -> Result<DesignView, String> {
if run
&& session
.turn
.as_ref()
.is_some_and(|turn| turn.model_selection.is_none())
{
resolve_design_turn_llm_config(&mut session, &load_game_creator_app_config()?)?;
}
checkpoint_design(root, &session)?;
let turn_id = session
.turn
@@ -1071,15 +1116,15 @@ pub(crate) async fn continue_design_agent_at(
.ok_or("策划 Agent 当前正在工作")?;
let mut session = match read_design_session(root)? {
Some(session) => session,
None => new_design_session(
&project_id,
&load_game_creator_app_config()?.selected_model_id,
),
None => new_design_session(&project_id, ""),
};
if session.project_id != project_id {
return Err("策划会话与当前项目不匹配".into());
}
let run = prepare_design_input(&mut session, id, input)?;
if run {
select_design_turn_model(&mut session, &load_game_creator_app_config()?)?;
}
finish_design_command(root, resources, session, active, run, emit).await
}
@@ -1110,6 +1155,9 @@ pub(crate) async fn decide_design_phase_at(
return Err("策划会话与当前项目不匹配".into());
}
let run = prepare_design_decision(&mut session, id, request_id, approved)?;
if run {
select_design_turn_model(&mut session, &load_game_creator_app_config()?)?;
}
finish_design_command(root, resources, session, active, run, emit).await
}
@@ -1541,6 +1589,223 @@ mod tests {
fs::write(root.join("design_artifacts/project/速览卡.md"), "速览").expect("write");
}
#[test]
fn design_turn_selection_survives_config_changes_and_legacy_recovery() {
let mut config = GameCreatorAppConfig::default();
config.selected_model_id = "chosen-model".into();
config.llm.reasoning_effort = "max".into();
config.agent_llm.insert(
"design-agent".into(),
serde_json::from_value(json!({
"model": "agent-override", "reasoningEffort": "low"
}))
.unwrap(),
);
let mut session = new_design_session("project", "old-model");
begin_design_turn(&mut session, "new-turn");
select_design_turn_model(&mut session, &config).unwrap();
let saved = serde_json::to_value(&session).unwrap();
assert_eq!(
saved["turn"]["modelSelection"],
json!({
"model": "chosen-model", "reasoningEffort": "max"
})
);
config.selected_model_id = "later-model".into();
config.llm.model = "later-model".into();
config.llm.reasoning_effort = "medium".into();
let mut restored: DesignSession = serde_json::from_value(saved.clone()).unwrap();
let llm = resolve_design_turn_llm_config(&mut restored, &config).unwrap();
assert_eq!(llm.model, "chosen-model");
let request = build_design_request(&restored, &pack(), &llm).unwrap();
assert_eq!(
request.response_reasoning_effort,
Some(platform_llm::LlmResponseReasoningEffort::Max)
);
let mut legacy = saved;
legacy["turn"]
.as_object_mut()
.unwrap()
.remove("modelSelection");
let mut restored: DesignSession = serde_json::from_value(legacy).unwrap();
let llm = resolve_design_turn_llm_config(&mut restored, &config).unwrap();
assert_eq!(llm.model, "chosen-model");
assert_eq!(llm.reasoning_effort, "medium");
config.llm.reasoning_effort = "low".into();
assert_eq!(
resolve_design_turn_llm_config(&mut restored, &config)
.unwrap()
.reasoning_effort,
"medium"
);
}
#[tokio::test(flavor = "current_thread")]
async fn design_user_actions_apply_selection_and_tool_requests_keep_the_turn_snapshot() {
let (_temp, root, resources) = init_design_project();
let config_dir = tempfile::tempdir().unwrap();
let _config_guard = crate::tests::use_test_runtime_config_dir(config_dir.path().into());
let (sender, receiver) = std::sync::mpsc::channel();
let tool_response = json!({"id":"tool-response", "status":"completed", "output":[{
"type":"function_call", "id":"tool-item", "call_id":"status-call",
"name":"get_workflow_status", "arguments":"{}"
}]});
let final_response = json!({"id":"final-response", "status":"completed", "output":[{
"type":"message", "id":"reply", "role":"assistant", "status":"completed",
"content":[{"type":"output_text", "text":"完成", "annotations":[]}]
}]});
let base_url = crate::tests::spawn_mock_llm_raw_responses_with_capture(
vec![
tool_response,
final_response.clone(),
final_response.clone(),
final_response.clone(),
final_response.clone(),
final_response,
],
Some(sender),
);
let save_selection = |model: &str, effort: &str| {
fs::write(
config_dir.path().join(GAME_CREATOR_CONFIG_FILE_NAME),
serde_json::to_vec(&json!({
"selectedModelId": model, "selectedModelIsDefault": false,
"llm": {"customEnabled":true, "visibleModels":["model-a","model-b"],
"apiKey":"test-design-key", "baseUrl":base_url, "model":model,
"apiKind":"openai_responses", "reasoningEffort":effort,
"stream":false, "maxRetries":0, "requestTimeoutMs":10000}
}))
.unwrap(),
)
.unwrap();
};
let expect_request = |model: &str, effort: Option<&str>| {
let raw = receiver.recv_timeout(Duration::from_secs(2)).unwrap();
let body: Value = serde_json::from_str(raw.split_once("\r\n\r\n").unwrap().1).unwrap();
assert_eq!(body["model"], model);
assert_eq!(body["reasoning"]["effort"].as_str(), effort);
};
save_selection("model-a", "high");
let mut changed = false;
let first = continue_design_agent_at(
&root,
&resources,
"first",
DesignInput::Message {
text: "需求".into(),
},
|event| {
if !changed && event.text.as_deref() == Some("正在请求 Provider…") {
save_selection("model-b", "low");
changed = true;
}
},
)
.await
.unwrap();
assert!(first.session.last_error.is_none());
assert!(changed);
expect_request("model-a", Some("high"));
expect_request("model-a", Some("high"));
let second = continue_design_agent_at(
&root,
&resources,
"second",
DesignInput::Message {
text: "继续".into(),
},
|_| {},
)
.await
.unwrap();
assert!(second.session.last_error.is_none());
expect_request("model-b", Some("low"));
let mut session = read_design_session(&root).unwrap().unwrap();
let session_id = session.session_id.clone();
session.pending_clarification = Some(DesignClarificationRequest {
request_id: "question".into(),
question: "平台?".into(),
options: vec!["PC".into()],
created_at: 1,
});
write_design_session(&root, &session).unwrap();
save_selection("model-a", "medium");
let answered = continue_design_agent_at(
&root,
&resources,
"answer",
DesignInput::Clarification {
request_id: "question".into(),
option_index: Some(0),
text: None,
},
|_| {},
)
.await
.unwrap();
assert!(answered.session.last_error.is_none());
expect_request("model-a", Some("medium"));
let mut session = read_design_session(&root).unwrap().unwrap();
session.turn.as_mut().unwrap().pending = true;
session.last_error = Some("provider failure".into());
write_design_session(&root, &session).unwrap();
save_selection("model-b", "max");
let retried =
continue_design_agent_at(&root, &resources, "retry", DesignInput::Retry, |_| {})
.await
.unwrap();
assert!(retried.session.last_error.is_none());
expect_request("model-b", Some("max"));
let mut session = read_design_session(&root).unwrap().unwrap();
concept_artifacts(&root);
let approval = submit_design_phase_for_approval(&root, &mut session).unwrap();
write_design_session(&root, &session).unwrap();
save_selection("model-a", "default");
let approved = decide_design_phase_at(
&root,
&resources,
"approve",
&approval.request_id,
true,
|_| {},
)
.await
.unwrap();
assert!(approved.session.last_error.is_none());
assert_eq!(approved.session.current_phase, "top_design");
assert_eq!(approved.session.session_id, session_id);
expect_request("model-a", None);
let saved = read_design_session(&root).unwrap().unwrap();
assert!(saved
.history
.iter()
.any(|item| item["call_id"] == "status-call"));
assert!(!serde_json::to_string(&saved)
.unwrap()
.contains("test-design-key"));
// 已处理的审批命令不采样新配置,也不重新请求 Provider。
save_selection("model-b", "low");
decide_design_phase_at(
&root,
&resources,
"approve",
&approval.request_id,
true,
|_| {},
)
.await
.unwrap();
assert_eq!(
read_design_session(&root).unwrap().unwrap().turn,
saved.turn
);
}
#[test]
fn approval_submission_skips_remaining_tools() {
let temp = tempfile::tempdir().expect("tempdir");
@@ -1552,6 +1817,7 @@ mod tests {
pending: true,
request_index: 0,
attempt: 0,
model_selection: None,
});
session.pending_batch = Some(DesignToolBatch {
calls: vec![
@@ -2202,6 +2468,7 @@ mod tests {
pending: true,
request_index: 0,
attempt: 0,
model_selection: None,
});
session.pending_batch = Some(DesignToolBatch {
calls: vec![call],
@@ -81,7 +81,7 @@ pub(crate) struct DesignSession {
pub(crate) engine: String,
pub(crate) session_id: String,
pub(crate) project_id: String,
/// 入口选择的 AGC 模型目录 ID;同一会话内保持稳定,不保存上游真实模型名
/// 最近一次用户执行采用的模型标识;旧活动回合缺快照时也据此恢复
#[serde(default)]
pub(crate) model_id: String,
pub(crate) current_phase: String,
@@ -124,6 +124,16 @@ pub(crate) struct DesignTurn {
pub(crate) pending: bool,
pub(crate) request_index: u64,
pub(crate) attempt: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) model_selection: Option<DesignModelSelection>,
}
/// 仅保存恢复所需的用户选择,不包含连接配置或凭据。
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct DesignModelSelection {
pub(crate) model: String,
pub(crate) reasoning_effort: String,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
@@ -280,7 +280,7 @@ pub(crate) struct TestConfigGuard {
previous: Option<Vec<u8>>,
}
struct TestRuntimeConfigDirGuard {
pub(crate) struct TestRuntimeConfigDirGuard {
_lock: StdMutexGuard<'static, ()>,
previous: Option<PathBuf>,
}
@@ -1325,7 +1325,7 @@ fn test_local_config_defaults_mock_provider_to_non_streaming_and_preserves_expli
assert_eq!(defaulted["llm"]["stream"], false);
}
fn use_test_runtime_config_dir(path: PathBuf) -> TestRuntimeConfigDirGuard {
pub(crate) fn use_test_runtime_config_dir(path: PathBuf) -> TestRuntimeConfigDirGuard {
let lock = TEST_CONFIG_LOCK
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
@@ -2477,7 +2477,7 @@ fn spawn_mock_llm_tool_plan_then_transient_final_compaction(
(base_url, handle)
}
fn spawn_mock_llm_raw_responses_with_capture(
pub(crate) fn spawn_mock_llm_raw_responses_with_capture(
response_bodies: Vec<serde_json::Value>,
request_sender: Option<mpsc::Sender<String>>,
) -> String {
@@ -267,6 +267,7 @@ export function ProjectSupervisorView({
...runtimePanelProps
}: ProjectSupervisorViewProps) {
const designAgentActive = Boolean(designView || onDesignApprove);
const showModelControls = directCodex || designAgentActive;
const [settingsOpen, setSettingsOpen] = useState(false);
// 语音输入的降级/失败提示:不支持时按钮本身就带提示,这里只承载启动失败与权限类错误。
const [voiceNotice, setVoiceNotice] = useState('');
@@ -772,44 +773,46 @@ export function ProjectSupervisorView({
}
onChange={onChatInputChange}
/>
{directCodex ? (
{showModelControls ? (
<div className="project-supervisor-composer-controls">
<div className="project-supervisor-composer-controls-left">
<button
type="button"
className="project-supervisor-reference-trigger"
aria-label="插入素材引用"
title="插入素材引用"
disabled={runtimePanelProps.controlBusy || needsUserInput}
onClick={() => composerRef?.current?.openPicker()}
>
<AtSign size={15} aria-hidden="true" />
</button>
</div>
{directCodex ? (
<div className="project-supervisor-composer-controls-left">
<button
type="button"
className="project-supervisor-reference-trigger"
aria-label="插入素材引用"
title="插入素材引用"
disabled={runtimePanelProps.controlBusy || needsUserInput}
onClick={() => composerRef?.current?.openPicker()}
>
<AtSign size={15} aria-hidden="true" />
</button>
</div>
) : null}
<div className="project-supervisor-composer-controls-right">
{/* 推理档放在模型选择器旁边(Codex 的「高」那个位置):写回的是客户端
配置,只影响后续回合;当前回合的行为不受影响。 */}
{/* 两种对话共用客户端配置控件;运行时何时采用选择由各自入口负责。 */}
<ComposerReasoningEffortSelect disabled={needsUserInput} />
<ConversationModelSelect
ref={modelSelectRef}
// 允许在对话进行中切换模型:写回的是客户端配置,只影响后续轮次,
// 当前回合不受影响;发送按钮仍由 controlBusy / modelReady 把关。
ref={directCodex ? modelSelectRef : undefined}
// 策划只复用配置控件;GameAgent 保留原有提交校验。
disabled={needsUserInput}
onReady={setModelReady}
onReady={directCodex ? setModelReady : undefined}
projectPath={projectPath}
/>
<ComposerVoiceButton
disabled={
runtimePanelProps.controlBusy ||
needsUserInput ||
modelValidating
}
onTranscript={(text) =>
composerRef?.current?.insertText(text)
}
onNotice={setVoiceNotice}
/>
{submitting && onCancelTurn ? (
{directCodex ? (
<ComposerVoiceButton
disabled={
runtimePanelProps.controlBusy ||
needsUserInput ||
modelValidating
}
onTranscript={(text) =>
composerRef?.current?.insertText(text)
}
onNotice={setVoiceNotice}
/>
) : null}
{directCodex && submitting && onCancelTurn ? (
<ComposerStopButton
cancelling={turnCancelling}
onCancel={onCancelTurn}
+23 -2
View File
@@ -10400,6 +10400,7 @@ button.design-workspace-tree__entry:hover,
/* 策划工作台保留标题行,避免共用跨行规则将标题挤到底部。 */
.game-workbench-layout--design .game-workbench-chat {
grid-template-rows: auto minmax(0, 1fr);
overflow: visible;
}
.game-workbench-layout--design .game-workbench-chat .project-supervisor-surface {
@@ -10411,7 +10412,7 @@ button.design-workspace-tree__entry:hover,
height: 100%;
min-height: 0;
padding: 10px;
overflow: hidden;
overflow: visible;
}
.game-workbench-layout--design .project-supervisor-conversation {
@@ -10419,7 +10420,7 @@ button.design-workspace-tree__entry:hover,
grid-template-rows: auto minmax(0, 1fr) auto auto auto;
height: 100%;
min-height: 0;
overflow: hidden;
overflow: visible;
}
.game-workbench-layout--design .project-supervisor-message-list {
@@ -10431,6 +10432,21 @@ button.design-workspace-tree__entry:hover,
.game-workbench-layout--design .project-supervisor-composer {
min-height: 0;
grid-template-columns: minmax(0, 1fr);
}
/* 策划复用控件排,输入区与控制排纵向排列;菜单继续锚定原控件。 */
.game-workbench-layout--design .project-supervisor-composer-controls-right {
flex: 1 1 auto;
justify-content: flex-end;
}
.game-workbench-layout--design .project-supervisor-reasoning-effort {
flex: 0 0 auto;
}
.game-workbench-layout--design .conversation-model-trigger {
max-width: 100%;
}
@media (max-width: 760px) {
@@ -10829,6 +10845,7 @@ button.design-workspace-tree__entry:hover,
color: var(--platform-text-soft);
}
.game-workbench-layout--design .project-supervisor-composer-controls,
.game-workbench-chat
.project-supervisor-surface.is-direct-codex
.project-supervisor-composer.is-direct-codex
@@ -10842,6 +10859,7 @@ button.design-workspace-tree__entry:hover,
pointer-events: auto;
}
.game-workbench-layout--design .project-supervisor-composer-controls-right,
.game-workbench-chat
.project-supervisor-surface.is-direct-codex
.project-supervisor-composer.is-direct-codex
@@ -10883,6 +10901,7 @@ button.design-workspace-tree__entry:hover,
菜单跑到**整个 composer 上方**和触发钮之间隔着整个输入区输入区一变高多行
引用 chipAI 润色菜单与提示就跟着往上飘看起来就是"编辑框把弹层挤开了"
`relative` 不会把它移出控制排`right/bottom: auto` 仍在原位只补回锚点 */
.game-workbench-layout--design .project-supervisor-composer-controls .conversation-model-select,
.game-workbench-chat
.project-supervisor-surface.is-direct-codex
.project-supervisor-composer.is-direct-codex
@@ -10918,6 +10937,7 @@ button.design-workspace-tree__entry:hover,
/* 控制排三只方钮`+` 附件 / `@` 引用 / 发送共用一套尺寸发送钮单独加圆角与主色
见下面两条规则 */
.game-workbench-layout--design .project-supervisor-composer-controls .project-supervisor-submit-button,
.game-workbench-chat
.project-supervisor-surface.is-direct-codex
.project-supervisor-composer.is-direct-codex
@@ -10981,6 +11001,7 @@ button.design-workspace-tree__entry:hover,
}
/* 发送钮是圆形主色块(Codex 观感):直径与左右两只方钮同档,圆角收到 999px。 */
.game-workbench-layout--design .project-supervisor-composer-controls .project-supervisor-submit-button,
.game-workbench-chat
.project-supervisor-surface.is-direct-codex
.project-supervisor-composer.is-direct-codex
@@ -11,6 +11,32 @@ import {
waitFor,
} from './harness';
function renderDesignAgent(
harness: ReturnType<typeof createProjectSupervisorRuntimeHarness>,
) {
window.__TAURI__ = {
core: { invoke: harness.invoke },
event: { listen: harness.listen },
};
window.history.pushState({}, '', '/');
return render(
React.createElement(App, {
initialProjectPath: harness.projectPath,
orchestrationMode: 'single-supervisor',
planningStartMode: true,
projectSupervisorOnly: true,
}),
);
}
async function expectDesignModelReady() {
await waitFor(() => {
expect(
screen.getByRole('button', { name: '对话模型' }).textContent,
).toContain('高质量');
});
}
function designApprovalView() {
return {
session: {
@@ -97,6 +123,105 @@ function designHistoryView() {
}
export function registerDesignAgentSurfaceTests() {
it('persists Design Agent model and reasoning controls and reads them on reentry', async () => {
const harness = createProjectSupervisorRuntimeHarness({
designAgentView: designConversationView(),
});
const first = renderDesignAgent(harness);
await expectDesignModelReady();
const reasoning = screen.getByRole('button', { name: '推理档' });
expect(
reasoning.closest('.project-supervisor-composer-controls'),
).not.toBeNull();
fireEvent.click(reasoning);
fireEvent.click(screen.getByRole('option', { name: '低' }));
await waitFor(() => {
expect(harness.invoke).toHaveBeenCalledWith(
'select_game_creator_reasoning_effort',
{ effort: 'low' },
);
expect(
screen.getByRole('button', { name: '推理档' }).textContent,
).toContain('低');
});
fireEvent.click(screen.getByRole('button', { name: '对话模型' }));
fireEvent.click(await screen.findByRole('option', { name: '快速' }));
await waitFor(() => {
expect(harness.invoke).toHaveBeenCalledWith('select_game_creator_model', {
modelId: 'fast',
isDefault: false,
});
expect(
screen.getByRole('button', { name: '对话模型' }).textContent,
).toContain('快速');
});
first.unmount();
renderDesignAgent(harness);
await waitFor(() => {
expect(
screen.getByRole('button', { name: '推理档' }).textContent,
).toContain('低');
expect(
screen.getByRole('button', { name: '对话模型' }).textContent,
).toContain('快速');
});
expect(
harness.invoke.mock.calls.some(
([command]) => command === 'continue_design_agent_session',
),
).toBe(false);
});
it('allows Design Agent model changes while running without submitting a new turn', async () => {
const harness = createProjectSupervisorRuntimeHarness({
designAgentView: { ...designConversationView(), running: true },
});
renderDesignAgent(harness);
await expectDesignModelReady();
expect(
(screen.getByRole('button', { name: '推理档' }) as HTMLButtonElement)
.disabled,
).toBe(false);
fireEvent.click(screen.getByRole('button', { name: '对话模型' }));
fireEvent.click(await screen.findByRole('option', { name: '快速' }));
await waitFor(() => {
expect(
screen.getByRole('button', { name: '对话模型' }).textContent,
).toContain('快速');
});
const input = screen.getByLabelText('项目需求');
expect(input.closest('[data-disabled="true"]')).not.toBeNull();
const send = screen.getByRole('button', { name: '思考中' });
expect(send).toHaveProperty('disabled', true);
fireEvent.click(send);
expect(
harness.invoke.mock.calls.some(
([command]) => command === 'continue_design_agent_session',
),
).toBe(false);
});
it('shows Design Agent reasoning save failures and preserves the saved selection', async () => {
const harness = createProjectSupervisorRuntimeHarness({
designAgentView: designConversationView(),
});
const invoke = harness.invoke.getMockImplementation()!;
harness.invoke.mockImplementation(async (command, args) => {
if (command === 'select_game_creator_reasoning_effort') {
throw new Error('save failed');
}
return invoke(command, args);
});
renderDesignAgent(harness);
await expectDesignModelReady();
const reasoning = screen.getByRole('button', { name: '推理档' });
await waitFor(() => expect(reasoning.textContent).toContain('高'));
fireEvent.click(reasoning);
fireEvent.click(screen.getByRole('option', { name: '低' }));
await screen.findByText('推理档保存失败');
expect(reasoning.textContent).toContain('高');
});
it('resumes following Design Agent messages when sending after scrolling up', async () => {
const harness = createProjectSupervisorRuntimeHarness({
designAgentView: designHistoryView(),
@@ -534,6 +534,8 @@ function createProjectSupervisorRuntimeHarness({
);
let messageSequence = 0;
let selectedModelId = 'quality';
let selectedModelIsDefault = false;
let reasoningEffort = 'high';
let steerSequence = 0;
let sessionExists = initialSessionExists;
let currentProjectRevision = initialProjectRevision;
@@ -605,9 +607,11 @@ function createProjectSupervisorRuntimeHarness({
}) => void)
| null = null;
let designAgentUpdateHandler:
((event: { payload: Record<string, unknown> }) => void) | null = null;
| ((event: { payload: Record<string, unknown> }) => void)
| null = null;
let directThreadNotifyHandler:
((event: { payload: { subscriptionId: string } }) => void) | null = null;
| ((event: { payload: { subscriptionId: string } }) => void)
| null = null;
let directThreadSubscriptionId: string | null = null;
let directThreadSubscriptionSequence = 0;
// 未消费的运行态事件队列:`subscribe` 的 bootstrap 与 `consume` 都从这里取,
@@ -672,22 +676,29 @@ function createProjectSupervisorRuntimeHarness({
async (command: string, args?: Record<string, unknown>) => {
if (
command === 'read_game_creator_app_config' ||
command === 'select_game_creator_model'
command === 'select_game_creator_model' ||
command === 'select_game_creator_reasoning_effort'
) {
if (command === 'select_game_creator_model')
if (command === 'select_game_creator_model') {
selectedModelId = String(args?.modelId);
selectedModelIsDefault = args?.isDefault === true;
}
if (command === 'select_game_creator_reasoning_effort') {
reasoningEffort = String(args?.effort);
}
return {
path: '/tmp/test-game-creator-config.json',
config: {
schemaVersion: 'game-creator-config.v2',
agentMode: 'codex_app_server',
selectedModelId,
selectedModelIsDefault,
llm: {
apiKey: '',
baseUrl: '',
model: 'quality',
apiKind: 'openai_responses',
reasoningEffort: 'high',
reasoningEffort,
stream: true,
webSearchEnabled: true,
contextWindowTokens: 128000,
@@ -0,0 +1,45 @@
# 策划 Agent 回合模型选择生效实施计划
| 字段 | 值 |
| --- | --- |
| Milestone | [回合模型选择生效里程碑](./【里程碑】策划Agent回合模型选择生效-2026-09-20.md) |
| Status | implemented(待验收) |
| Owner | 当前任务 Agent |
## 修改边界与顺序
1. 在本地 `DesignTurn` 中追加可缺省的模型/推理档选择快照,仅存两个非敏感字段。现有 UI 只投影会话摘要,无需扩展前端 DTO。
2. 策划普通发送、澄清回答、用户重试和批准阶段实际开始执行时,从现有配置加载结果获取全局模型和推理档,覆盖上一轮选择;重复命令、拒绝审批和纯读取不重新采样。
3. Provider 请求使用当前回合快照,工具循环与自动重试期间不随全局选择变化。保留其它连接配置的原解析,策划专属配置不能覆盖这两个用户选择字段。
4. 自动恢复沿用活动回合快照;旧记录缺快照时保留已有会话模型,并从当前全局配置补推理档,再由原检查点保存。保持工具幂等和审批流程。
5. 补充既有 Rust 测试,验证请求字段、新旧会话、重试、审批/澄清和恢复;更新主规范与稳定项目记忆。
## 非目标
不改两个前端控件、GameAgent、模型目录、默认值、供应商适配;不新增模型可用性检查、自动换模型或提交门禁。不保存凭据或完整配置,不引入通用快照框架。
## 验证
- `cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml design_runtime::tests -- --test-threads=1`
- `cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml design_session -- --test-threads=1`
- 改动文件 Rust 格式检查;文档索引、编码检查和 `git diff --check`
- 优先本地 HTTP fixture 核对发送给 Provider 的模型与推理档,真实 Provider 未运行时明确记录。
## 风险与回滚
模型选择快照必须在首个副作用前持久化,配置读取失败不能改变既有持久状态;旧记录自动恢复不推断历史推理档。新增可缺省字段支持现有会话读取,回滚不删除会话或产物。
时间盒:先完成最小采样/请求闭环和定向测试,再核对恢复与文档。仅在影响本里程碑判据时扩大范围。
## 验证记录
| 验证 | 结果 |
| --- | --- |
| 改动文件 Rust 格式检查、diff 检查 | 通过 |
| 文档索引、编码检查 | 通过 |
| Rust 定向测试 | 已尝试编译,未执行测试用例;不记为通过 |
| 本地 HTTP fixture | 已补充真实请求字段断言,尚未执行 |
| 桌面/真实 Provider smoke | 未运行 |
| 独立静态审查 | 已检查采样入口、重放/恢复边界、请求循环和测试辅助函数可见性,无可操作发现 |
新增用例验证回合快照与旧数据读取,以及普通消息、跨工具请求、澄清回答、用户重试、审批批准、重复审批的配置生效边界。已有恢复用例继续覆盖不确定文件操作不重复执行。控件和 GameAgent 无本步修改。
@@ -0,0 +1,49 @@
# 策划 Agent 模型与推理档控件接入实施计划
| 字段 | 值 |
| --- | --- |
| Milestone | [控件接入里程碑](./【里程碑】策划Agent模型与推理档控件接入-2026-09-20.md) |
| Status | accepted |
| Owner | 当前任务 Agent |
## 修改边界
-`ProjectSupervisorView.tsx` 扩展既有控件排的适用范围,直接复用 `ConversationModelSelect``ComposerReasoningEffortSelect`。素材引用、语音、消息队列和停止按钮仍按原 GameAgent 条件显示。
- 策划发送、澄清、审批和重试回调保持原样,不新增模型校验和提交门禁;GameAgent 的原校验保持不变。
- 必要时局部调整策划宿主 CSS,避免控件换行或弹层裁切。组件内部逻辑保持不变。
- 在现有前端测试体系补齐策划集成场景,回归已有 GameAgent 控件用例。
- 不修改 Rust、模型生效逻辑、配置默认值、Provider 或 GameAgent 组件算法。
## 实现顺序
1. 核对共享对话容器,接入同一组控件,保留原提交路径。
2. 补充策划控件读写、保存失败和忙态测试,回归原策划和 GameAgent 行为。
3. 运行定向测试和类型检查,检查宽窄布局,回写证据与未验证项。
## 验证命令
- `npm test -- apps/ai-game-creator-shell/tests/appSurface.test.ts`,先按用例名称过滤控件及相关策划场景。
- 策划宿主集成用例使用现有 Vitest / Testing Library,按实际测试文件定向运行。
- `npm run ai-game-creator-shell:typecheck`
- `npm run check:doc-index`
- `npm run check:encoding`
- `git diff --check`
## 风险与回滚点
- 防止扩大 `directCodex` 条件时带入语音、队列或改变 GameAgent 行为:仅共享控件排,保留其它分支。
- 模型目录失败通过原选择器反馈,不给策划宿主增加前置检查,也不改造控件已有逻辑。
- 本次回滚仅撤回前端接入、相关用例和文档,不涉及持久化数据迁移。
- 时间盒:一个工作时段内先完成接入与定向测试,随后检查类型和布局;仅影响里程碑验收的发现纳入修复。
- 当前已知限制:策划旧会话仍可能使用创建时模型;本次不将控件读写成功视为运行时已切换模型。
## 验收证据
| 项目 | 结果 |
| --- | --- |
| 源码边界核对 | 两个控件文件、策划动作回调、GameAgent 提交校验及 Rust 均无修改;仅扩展控件排适用条件与策划布局 |
| 定向 ESLint | `ProjectSupervisorView.tsx``design-agent.suite.ts``harness.ts` 通过 |
定向测试命令:`npm test -- apps/ai-game-creator-shell/tests/appSurface.test.ts -t 'Design Agent|design session|clarification free text|reasoning effort control|selecting the model|model dropdown|chat composer an inset|current turn reasoning|historical reasoning'`
用户已授权提交第一步、进入第二步。
@@ -0,0 +1,48 @@
# 策划 Agent 回合模型选择生效
| 字段 | 值 |
| --- | --- |
| Version | 1.0 |
| Status | implemented(待验收) |
| Date | 2026-09-20 |
| Parent Spec | [策划 Agent 生产迁移与工作区浏览方案 §4.1](../../technical/【技术方案】策划Agent生产迁移与工作区浏览-2026-09-10.md#41-策划对话模型与推理档选择) |
## 目标
策划已有会话在后续执行时真正采用控件保存的模型和推理档,执行途中保持选择稳定,恢复不破坏既有工具副作用边界。
## 范围
- 新会话、旧会话后续消息、回答澄清、阶段审批继续和用户主动重试的选择生效。
- 同一次执行中的工具循环、自动重试固定使用开始时的选择。
- 活动回合自动恢复所需的最小模型、推理档快照及旧会话缺字段读取。
- 模型切换保留策划上下文、阶段、审批和产物。
## 不在范围内
- 不改变 GameAgent Runtime 或既有配置控件算法。
- 不调整模型目录、默认档位、供应商协议、提示词和阶段规则。
- 不建立 Agent 专属设置、通用快照框架、历史转换器或平行账本。
## 依赖与前置条件
- [控件接入里程碑](./【里程碑】策划Agent模型与推理档控件接入-2026-09-20.md) 已验收。
- 主规范和本里程碑已评审,实施前编写仅覆盖本步的实现计划。
- 生效时机、主动重试与自动恢复的区分遵循主规范,不通过重建会话实现切换。
## 验收标准
- [ ] 新策划会话和旧策划会话下一次执行都使用当前已保存选择;旧模型不再永久覆盖新选择。
- [ ] 发送、澄清、审批继续与主动重试的实际 Provider 请求使用新选择。
- [ ] 执行中改配置不影响本次后续工具请求或瞬态自动重试,下一次用户执行才生效。
- [ ] 自动恢复优先使用已有活动回合快照;旧记录缺推理档时按主规范补齐,不重复已执行工具副作用。
- [ ] 切换后上下文、阶段、产物和审批身份保留,纯读取不改历史模型信息。
- [ ] 配置/Provider 失败沿用可见错误,不静默降级、换模型或清空历史。
- [ ] GameAgent 既有选择和运行行为通过兼容回归,没有借机修正其本征不足。
## 证据要求
- 自动化:本地 Provider fixture 捕获请求模型和推理档;覆盖跨工具调用、自动重试、用户重试、审批/澄清继续及旧会话恢复;运行相关 Rust 定向测试、必要类型检查、编码和文档索引检查。
- 运行时:在现有策划项目选择另一模型及推理档,触发下一次执行核对实际请求;真实供应商是否接受跨模型历史需按环境记录实测或未验证。
- 边界:验证恢复幂等和凭据不进入新增快照;无需新增通用权限或供应商测试体系。
- 全部验收后将主规范提案改为当前行为,更新稳定项目记忆并删除本次已完成临时计划。
@@ -0,0 +1,53 @@
# 策划 Agent 模型与推理档控件接入
| 字段 | 值 |
| --- | --- |
| Version | 1.0 |
| Status | accepted(用户已授权提交、进入第二步) |
| Date | 2026-09-20 |
| Parent Spec | [策划 Agent 生产迁移与工作区浏览方案 §4.1](../../technical/【技术方案】策划Agent生产迁移与工作区浏览-2026-09-10.md#41-策划对话模型与推理档选择) |
## 目标
策划对话复用已有模型和推理档控件,能够读取和保存全局选择,保持 GameAgent 兼容性。
## 范围
- 输入区域中控件的显隐、位置、策划面板布局与必要的宿主接线。
- 复用原有目录、默认模型、保存和错误提示,不复制逻辑。
- 策划发送、澄清、审批继续和主动重试保持原流程;不新增模型可用性检查或提交门禁。
- 重新进入策划时显示保存值;执行中可以调整后续选择,但原输入与提交忙态维持不变。
## 不在范围内
- 不修改策划 Provider 的模型选择和推理档采样逻辑,不声称旧会话已能切模型。
- 不检查、修复或重构 GameAgent 组件本征不足,不引入通用缓存、同步、保存、下拉或配置框架改造。
- 不为策划引入队列、语音或素材引用,不更改默认推理档。
## 依赖与前置条件
- 主规范本节与本里程碑完成评审。
- 实施前仅为本里程碑编写实现计划,确认现有 GameAgent 对应回归用例与策划宿主入口。
- 本步作为内部接入结果;第二步完成前不单独发布为完整可切模型功能。
## 验收标准
- [ ] 策划入口显示原模型及推理档控件,没有重复实现或第二份设置。
- [ ] 选择走原保存通道;重新进入显示已保存值;保存失败显示原有错误反馈。
- [ ] 策划提交和报错流程保持原样,没有新增模型检查;原控件内部行为不改造。
- [ ] 策划忙态、待审批/澄清行为保持;新增布局在宽/窄面板可操作。
- [ ] GameAgent 原控件的显示、选择、发送校验、忙态和运行行为通过相关回归。
- [ ] 交付明确记录旧策划会话仍沿用旧模型,留待下一里程碑解决。
## 证据要求
- 自动化:现有界面测试中增加策划入口的集成场景,复用 GameAgent 回归;运行 AGC 类型检查、文档索引、编码和 diff 检查。
- 运行时:桌面与窄面板选择、保存、重新进入和一次策划发送 smoke;缺失环境如实标注。
- 边界:只验证接入所需的失败和忙态,不扩展为现有组件全量审计。
- 通过本步验收后,才准备并执行模型生效里程碑的实现计划。
## 当前交付
已接入原控件及策划布局,按用户最终要求保留策划提交、审批、澄清、重试与报错流程,没有新增模型检查。原控件与 GameAgent 的提交逻辑未修改;旧策划会话固定模型的限制仍由第二步解决。
新增三条控件集成用例(保存重进、运行中选择、保存失败反馈)。用户已授权提交第一步、进入第二步。
@@ -6,6 +6,8 @@
## 2026-09-20 Godot 编辑器执行接入
原生引导采用固定版本的官方 `godot-cpp` 和 MSVC x64 构建,绑定及 C++ runtime 静态链接。依赖归档和缓存源码须核验,安装目录仍只分发原生载荷及许可。EDITOR 阶段动态加载/卸载时显式清理 C++ 实例绑定与单例包装,保留纯 GDScript 的异步执行和原有协议;执行权限、项目身份与缓存归属继续由现有宿主处理。
可用性边界按引擎区分:Cocos/Unity 保持不按工程类型过滤,Godot 仍绑定当前 Godot 项目,切项目撤销旧插件上下文;前端统一根据宿主投影启动插件。Runtime 工具目录只对 Godot 追加项目条件,编辑器说明沿用外置提示词及审核 Skill 参考。
Godot 编辑器操控复用既有 AGC 插件宿主、EditorAdapter、Runner 和不确定执行回执合同,编辑器实现留在 `plugins/agc-godot-editor`。用户选择 DLL 原件随 AGC 安装资源分发,并确认按编辑器实例在 AGC 私有缓存准备临时加载副本,以满足 Godot Windows 加载器的同目录 `~DLL` 写入要求;项目内不复制 DLL,只用受管 `.gdextension` 引导。Godot 自动 UID 伴生文件必须记录归属并在确认卸载后按内容匹配清理。工作区根不迁移到 Godot 子目录,原始项目配置与场景只通过明确编辑操作修改。完整合同及验证范围见 [Godot 编辑器插件接入](<../../technical/【技术方案】AGC Godot编辑器插件接入-2026-09-20.md>)。
@@ -1,9 +1,9 @@
# 踩坑与排障记录
## Godot 原生插件在 MSVC C 模式下的对齐声明
## Godot C++ 扩展构建与对象生命周期
- `native.c` 若首先报 `max_align_t` 语法错误,后面的 `Storage``retained_script` 等未声明通常是连带错误。MSVC C 模式不提供该类型;ABI 存储使用 C11 `_Alignas(16)` 显式对齐并保持 128 字节容量,通过实际 MSVC DLL 构建验证,不逐条修补连带错误
- `No C compiler found` 则属于开发环境问题:先初始化 Visual Studio x64 开发环境,同时设置 PATH、INCLUDE 与 LIB,再运行构建。只把 `cl.exe` 所在目录加到 PATH 不足以提供头文件和链接库
- 原生引导通过官方 `godot-cpp` 管理 Variant、String 和 Ref,不自行维护 ABI 存储。Godot 类型必须在扩展终止回调内释放,不能依赖 DLL 静态对象析构;桥节点可能已经退出,应按实例 ID 核验存活再回调
- 正式 Windows 构建使用 CMake 的 Visual Studio x64 generator,并实际验证 MSVC 编译;不能用 GCC 成功替代 MSVC 验收。固定官方归档按 SHA256 校验,缓存源码被修改时拒绝构建并保留证据
## Rust 同步回调的测试记录按线程隔离
@@ -22,6 +22,8 @@
- 策划 Agent 的顾问态由用户指示驱动,不自主推进项目、主动安排下一步或提交阶段审批;完成单次请求不结束顾问态。五个策划阶段的审批用于检阅已完成产物,关键选择先问询;过程文档按需记录且不重复正式正文。顶层设计按需保留易混淆方向及排除理由,提示词精简应保留这些行为与设计边界。详见策划 Agent 生产迁移与工作区浏览方案。
- 策划 Agent 复用现有模型/推理档控件,宿主不另加模型检查或自动换模型。用户发起执行时采样全局选择,同轮工具循环和自动重试固定使用回合快照;自动恢复复用该快照,旧记录保留已知模型并补齐一次推理档。只持久化模型和档位,不保存连接凭据;GameAgent 保持原逻辑。详见策划 Agent 生产迁移与工作区浏览方案 §4.1。
- AGC 思考与执行入口共用共享单行摘要骨架;Markdown 在展开正文走既有安全渲染,折叠预览使用纯文本。耗时统一复用中文时分秒格式(不足一分钟一位小数,达到分钟后整数秒),格式化与各层计时边界分离。过程行在运行中和完成后的折叠层内保持同一紧凑间距;失败状态按明确终态与非零退出码呈现红色。
- Direct 对话计时区分条目展示时间与生命周期事件时间:整轮用用户发送到明确终态的跨度,工具用各自开始/完成边界;运行时用 100ms 叶子时钟刷新一位小数,终态冻结,旧历史缺边界不推测。不得用整秒时间的大小比较取代 Thread Manager 的事件顺序判定新回合。
@@ -33,7 +33,10 @@ DLL 原件随 AGC 安装包放在插件资源目录中;Godot Windows 加载器
## 分发与描述文件
- Windows 原生构建使用 x64 C11 编译器;MSVC 需先初始化 Visual Studio 的 x64 开发环境,以同时提供 PATH、INCLUDE 与 LIB。ABI 临时存储使用 128 字节、C11 `_Alignas(16)` 显式对齐,不依赖 MSVC C 模式未提供的 `max_align_t`;定向验证运行 `plugins/agc-godot-editor/native/gdextension/build.ps1 -Compiler cl.exe`
- 原生引导使用官方 `godot-cpp` 的 C++ 类型和初始化接口,不自行声明 Variant 存储或直接装配 ABI 函数指针。绑定源码固定到 `godot-4.5-stable``e83fd0904c13356ed1d4c3d09f8bb9132bdc6b77`,以该版本的稳定 API 构建并在 Godot 4.7.2 验证;产品最低版本仍为 4.7,因为嵌入脚本使用该版本能力。Windows x64 构建使用 Visual Studio C++、CMake 和 Python,静态链接绑定及 C++ runtime,用户无需这些构建工具
- 构建仅从已固定的官方归档获取绑定源码,并核对 SHA256;下载和生成内容只进入 `.build/`。CMake 构建包含引导源码、嵌入脚本、绑定版本/归档摘要和构建配置的身份指纹。许可证与来源继续随 DLL 分发,安装包不包含 SDK、源码缓存、生成绑定或构建工具。
- C++ 状态仅在扩展有效期间持有 GDScript 引用和桥节点身份;终止回调先停用仍存活的桥,再释放绑定对象,不能让静态 Godot 对象析构晚于绑定退出。延迟 bootstrap、原生卸载、Node 已退出及同 PID 重连均须实测。受管文件、会话身份、协议、执行回执与不确定阻断沿用现有合同。
- EDITOR 阶段晚加载不会取得 CORE 阶段终止回调;引导终止时须通过官方绑定接口解除 Node/GDScript 的实例包装回调,并完成单例包装清理。只移除 C++ 包装,不同步销毁仍在 GDScript 调用栈或等待 `queue_free` 的引擎对象;先用 Variant 保活脚本,再解除 Ref 和绑定,避免卸载 DLL 后跳到失效回调。
- 安装资源布局为 `plugins/agc-godot-editor/native/gdextension/bin/win-x64/agc_godot_editor.dll`,邻接元数据记录协议、构建身份和 DLL SHA256。开发模式允许宿主提供仓库插件目录中的同结构产物;RPC 不接受自定义 DLL 候选。
- 缓存根仅由宿主提供,为其私有配置目录下的 `godot-editor-runtime`;按 `PID + startedFileTime + buildId` 隔离,所有路径分量受控且拒绝链接/reparse point。复制前验证安装原件及元数据,缓存已有文件必须匹配来源、归属及 SHA,不能加载被替换的同名文件。受管描述同时保留原件与加载副本身份,重启恢复不得把工程给出的任意 DLL 路径当作受信任来源。
@@ -107,11 +110,13 @@ Windows x64、Godot 4.7.2 标准编辑器的本地实现验收通过。证据保
### 复验入口
从仓库根运行,原生构建要求 Windows x64 C 编译器。先把 `AGC_GODOT_TEST_EXECUTABLE` 设置为待验证的标准 Godot 编辑器绝对路径;未设置时 headless 测试会跳过,不能视为实机通过。
从仓库根运行,原生构建要求 Visual Studio 2022 C++ x64、CMake 3.25 及以上和 Python 3;通过 Visual Studio generator 自动选择完整编译环境。首次构建需访问固定的官方归档,校验后的依赖缓存支持离线复用;`build.ps1` 可用 `-CMake``-Python` 指定构建工具。先把 `AGC_GODOT_TEST_EXECUTABLE` 设置为待验证的标准 Godot 编辑器绝对路径;未设置时 headless 测试会跳过,不能视为实机通过。
```powershell
powershell -NoProfile -File plugins/agc-godot-editor/native/gdextension/build.ps1
python -X utf8 -B plugins/agc-godot-editor/native/gdextension/tests/test_dependencies.py
node --test plugins/agc-godot-editor/native/gdextension/tests/native-smoke.test.mjs
node --test plugins/agc-godot-editor/native/gdextension/tests/guide-examples.test.mjs
cargo test --locked --manifest-path plugins/agc-godot-editor/native/godot-editor-bridge/Cargo.toml
npm run agc:plugins:test
cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --features cocos-editor-execute,unity-editor-execute,godot-editor-execute godot
@@ -124,3 +129,9 @@ git diff --check
```
真实 GUI 使用 `native/godot-editor-bridge/examples/live_smoke.rs`;安装位置变更使用同目录的 `install_location_smoke.rs`。二者要求显式传入自有可丢弃工程、已打开编辑器 PID、可信安装 DLL、工程外私有缓存及 `--allow-fixture-mutations`,具体参数见源码用法。多实例验证分别传入两个工程和 PID,通过 `live_smoke``--hold-ms` 让加载时间重叠,同时核验原生模块路径。Runner 复验须经正式长度前缀 RPC、完整 ACK 和私有配置恢复路径,不能用原生示例替代 Runner 证据。
### C++ 引导验收边界
官方 C++ 绑定版已通过实际 MSVC 构建、原生/指南 headless 回归、Rust 缓存与连接回归、受管资源 staging 校验,以及同一 Godot 4.7.2 GUI 进程中的首次加载、执行、卸载和重新聚焦后的重连。DLL 的导入依赖只有 KERNEL32,生成 SDK/编译缓存不进入 staging;重复构建命中同一载荷身份,损坏归档和改动过的依赖缓存拒绝构建。验证中首次创建的空工程由 Godot 补写版本特征,按编辑器初始化后的基线确认插件运行不修改原有工程文件。
GUI 自动聚焦若未触发扫描,会按原合同返回未派发错误;重新聚焦后再连接,不重放未知执行。此次 C++ 替换未重新运行完整 AGC 发布构建、真实 Provider 或安装包 UI 验证,前述旧版本证据不代替这些验收。
@@ -116,7 +116,63 @@ Runtime 不维护文档版本号,不解析文档版本,不提供版本回退
会话/回合/工具调用身份用于生产恢复和重复请求处理,与 Agent 自行写在策划文档头部的版本号无关。
策划会话在创建时保存入口选择的 AGC 模型目录 ID(例如 `quality``fast`),同一会话后续回合沿用该 ID;客户端不保存或推断上游真实模型名。官方 `platform-llm` 直连 api-server 时携带 AGC 客户端标记,由 api-server 根据模型目录解析实际模型,不能在客户端硬编码某个上游模型替代目录选择。
策划每次用户发起执行时采用客户端当前保存的模型和推理档,保存在当前回合中;同一执行内的工具调用与自动重试保持该选择,后续用户执行重新读取。官方模式保存 AGC 模型目录 ID(例如 `quality``fast`),自定义模式保存已选择的型号;客户端不推断官方上游真实模型名。官方 `platform-llm` 直连 api-server 时携带 AGC 客户端标记,由 api-server 根据模型目录解析实际模型,不能在客户端硬编码某个上游模型替代目录选择。
### 4.1 策划对话模型与推理档选择
**交付结果**:策划 Agent 对话复用 GameAgent 的模型、推理档选择控件及配置通道,用户的新选择对后续策划回合实际生效,并保持 GameAgent 原有行为兼容。
本节是本次变更的唯一主规范。控件接入已完成并提交,运行时模型生效逻辑已实现、待验收。拆分与验收见 [控件接入里程碑](../project-memory/plans/【里程碑】策划Agent模型与推理档控件接入-2026-09-20.md) 和 [模型生效里程碑](../project-memory/plans/【里程碑】策划Agent回合模型选择生效-2026-09-20.md)。
#### 范围与非目标
- 必须项:复用已有控件及客户端配置读写;补齐策划入口;修正旧策划会话固定使用创建时模型的行为;验证 GameAgent 兼容性。
- 风险项:界面选择与实际请求不一致、执行中途切换配置、恢复旧会话,以及策划窄面板新增控件的布局。
- 可选项:无。发现与上述验收无关的问题,只记录发现,不扩展本次实现。
- 明确不做:不评估或重构 GameAgent 已有控件的本征不足,不重做模型目录、缓存、配置同步、下拉交互、通用保存队列或错误处理;不调整 GameAgent Runtime、推理默认值、供应商适配、提示词或阶段审批规则;不新建 Agent 专属设置、模型管理页、配置框架或测试框架。
- 优先在现有共享对话容器中复用同一组组件,不复制控件源码,不因新增一个使用方迁移整套配置型组件。若需要局部接口扩展,默认调用必须保持 GameAgent 现有行为。
#### 控件和配置合同
1. 策划对话输入框附近显示与 GameAgent 相同的模型和推理档控件,读取、选项、保存、错误反馈沿用已有能力;只调整策划宿主必要的显隐和布局。按用户确认,控件保持原样,不给策划发送、审批、澄清或重试新增模型可用性检查,也不以模型目录状态新增提交门禁;GameAgent 已有提交逻辑保持原样。
2. 继续使用客户端全局模型选择与全局推理档,不新增第二份策划设置。它们是客户端偏好,可能影响其它后续对话;进入或重开策划界面时显示当前保存值。既有 GameAgent 配置解析保持不变,不承诺新增跨窗口实时同步。
3. 策划运行时最终以选择器保存的全局模型和推理档作为本次用户选择;策划专属底层配置不得在这两个字段上静默覆盖选择。连接、鉴权、超时等其余字段继续按既有生产配置解析,不清理或改写用户其它配置。
4. 模型的官方目录别名、自定义模型目录、默认项跟随及失效项处理复用已有能力;不新增供应商能力探测或模型自动降级。推理档的枚举与默认值维持现状。
5. 可以在执行中修改后续选择;不会中断或重启正在执行的策划回合。策划保留原有输入、发送、审批和澄清忙态规则,不引入 GameAgent 的消息队列、素材引用或语音功能。
#### 生效边界
| 触发 | 目标行为 |
| --- | --- |
| 首次发送、后续普通发送 | 读取最新已保存的选择并开始新回合,不新增提交前模型检查 |
| 回答澄清、批准/拒绝阶段后实际继续调用 Provider | 同样使用本次继续前保存的选择;不更改审批结果和请求身份语义 |
| 用户主动点击失败重试 | 使用最新选择开始本次执行;继续现有工具恢复规则,不重复已完成副作用 |
| 同一执行内的工具循环、HTTP/流式瞬态自动重试 | 始终使用该次执行开始时确定的模型和推理档,不在每次 Provider 调用前重新采样这两个字段 |
| 纯读取、展示历史、未触发 Provider 的操作 | 不创建新回合,不覆盖历史使用的模型信息 |
| 进程中断后的自动续跑 | 优先沿用持久化的活动回合模型和推理档;不把它当成用户重新选模型后的新回合,不重复文件副作用 |
“后续回合生效”按上表定义,不以底层函数是否创建新 turn ID 为判断依据。无需重建会话或清空历史才能切模型,正式策划阶段、产物和上下文继续保留。
#### 失败、兼容与数据约束
- 控件保存失败和目录不可用沿用控件现有提示;策划发送、审批、澄清、重试和 Provider 报错流程保持原样。原控件内部的目录同步与默认模型处理不在本次改造范围,也不在策划宿主另加同类逻辑。
- Provider 不接受所选模型或历史上下文时,沿用现有可见错误和重试;不静默换模型、清历史或另建跨模型上下文转换系统。
- 已有设计会话无需离线迁移:下次用户发起执行时采用新选择。自动恢复的旧活动回合优先保留已有模型;若尚无推理档快照,使用当次有效配置补齐一次并固定,不伪称还原了历史档位。
- 持久化在当前回合追加可缺省的 `modelSelection`,只含 `model``reasoningEffort`;已有会话 `modelId` 表示最近一次用户执行采用的模型,兼作旧活动回合恢复依据。旧记录缺字段可读;不保存完整配置、端点凭据、Token 或 API Key。不增加平行会话账本,不改模型目录 ID 与真实型号的边界。
- 本次不涉及公开 HTTP API、OpenAPI 或 SpacetimeDB schema。若本地设计会话投影需要新增字段,同步其现有 Rust/TypeScript 定义和恢复用例。
#### 两步交付与验收
| 步骤 | 交付边界 | 完成证据 |
| --- | --- | --- |
| 第一步:控件接入 | 策划宿主显示并使用原控件,选择写入现有全局配置;保留策划原提交流程;不改策划请求的模型解析与快照逻辑 | 策划控件读写、忙态与失败提示定向测试;GameAgent 相关现有用例;宽/窄面板 smoke |
| 第二步:模型生效 | 在策划执行边界采样并固定模型与推理档,旧会话下次执行采用新选择,自动恢复保留活动回合选择 | 本地 Provider fixture 验证真实请求字段、跨工具调用一致性、重试与恢复;界面到请求 smoke;GameAgent 兼容回归 |
第一步是内部可验收的接入结果,不能宣称“策划旧会话切模型已生效”,也不能作为完整功能单独发布。第二步验收前保持这一已知限制明确。
两个步骤分别形成最小闭环;检查点分别为“控件与兼容验收”和“请求与恢复验收”。实现中的新增发现只有影响本节交付判据时才扩大范围。每步先用一个工作时段完成定向实现与验证;超过时段仍未闭环时,说明剩余阻碍并重估,不以顺手改造组件扩大任务。
验收至少覆盖:保存后重新进入策划显示一致;旧会话切换模型后实际请求改变;当前执行不被中途改档;下一次发送/澄清/审批继续/主动重试生效;自动恢复不重复工具副作用;GameAgent 原有选择、发送和运行行为不回归。真实 Provider 或桌面环境缺失时明确记为未验证,不用 fixture 冒充实机结果。
## 5. Agent Runtime
+2 -1
View File
@@ -83,7 +83,8 @@ feature 会构建自包含 Attach helper,并只将运行文件与许可放入
[Unity 插件接入](../docs/technical/【技术方案】AGC Unity编辑器插件接入-2026-09-18.md)。
Godot 插件通过 `godot-editor-execute` feature 构建并校验 GDExtension 载荷,只分发
运行入口、DLL、元数据及许可。构建机需要 Windows x64 C 工具链;DLL 原件留在安装资源,
运行入口、DLL、元数据及许可。原生引导使用固定版本的官方 `godot-cpp`;构建机需要
Visual Studio C++ x64、CMake 和 Python,首次构建下载并校验绑定源码。DLL 原件留在安装资源,
每个编辑器的临时加载副本放在 AGC 私有缓存。连接时维护工程内受管 `.gdextension`
引用及其 UID,通过 Godot 聚焦扫描首次加载。文件归属、真实执行和卸载规则见
[Godot 插件接入](<../docs/technical/【技术方案】AGC Godot编辑器插件接入-2026-09-20.md>)。
@@ -0,0 +1,22 @@
cmake_minimum_required(VERSION 3.25)
project(agc_godot_editor LANGUAGES CXX)
if(NOT MSVC OR NOT CMAKE_SIZEOF_VOID_P EQUAL 8)
message(FATAL_ERROR "Godot editor payload requires MSVC x64")
endif()
find_package(Python3 REQUIRED COMPONENTS Interpreter)
set(CMAKE_MSVC_RUNTIME_LIBRARY MultiThreaded CACHE STRING "Static runtime" FORCE)
set(GODOTCPP_TARGET editor CACHE STRING "Editor bindings" FORCE)
set(GODOTCPP_BUILD_PROFILE "${CMAKE_CURRENT_SOURCE_DIR}/build-profile.json" CACHE FILEPATH "Minimal bindings" FORCE)
set(GODOTCPP_USE_STATIC_CPP ON CACHE BOOL "Static runtime" FORCE)
set(GODOTCPP_USE_HOT_RELOAD OFF CACHE BOOL "Explicit unload/reload only" FORCE)
set(GODOTCPP_ENABLE_TESTING OFF CACHE BOOL "Do not package upstream tests" FORCE)
if(NOT EXISTS "${AGC_GODOT_CPP_SOURCE}/CMakeLists.txt")
message(FATAL_ERROR "Run build.ps1 to prepare the verified godot-cpp source")
endif()
add_subdirectory("${AGC_GODOT_CPP_SOURCE}" godot-cpp EXCLUDE_FROM_ALL SYSTEM)
add_library(agc_godot_editor SHARED src/native.cpp)
target_compile_features(agc_godot_editor PRIVATE cxx_std_17)
target_link_libraries(agc_godot_editor PRIVATE godot::cpp)
target_include_directories(agc_godot_editor PRIVATE "${AGC_GENERATED_DIR}")
target_compile_options(agc_godot_editor PRIVATE /W4 /WX /utf-8)
set_target_properties(agc_godot_editor PROPERTIES RUNTIME_OUTPUT_DIRECTORY_RELEASE "${CMAKE_BINARY_DIR}/payload")
@@ -0,0 +1,3 @@
{
"enabled_classes": ["GDScript", "Node", "OS", "ProjectSettings"]
}
@@ -1,84 +1,73 @@
param([string]$Compiler = $env:AGC_GODOT_C_COMPILER)
param(
[string]$CMake = 'cmake.exe',
[string]$Python = 'python.exe',
[string]$Generator = 'Visual Studio 17 2022',
[ValidateRange(1, 32)][int]$Jobs = 4
)
$ErrorActionPreference = 'Stop'
$root = $PSScriptRoot
if ([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT) { throw 'Godot editor native payload requires Windows x64.' }
if (-not [Environment]::Is64BitProcess) { throw 'A 64-bit build host is required.' }
if (-not $Compiler) {
foreach ($candidate in @('gcc.exe', 'clang.exe', 'cl.exe')) {
$found = Get-Command $candidate -ErrorAction SilentlyContinue
if ($found) { $Compiler = $found.Source; break }
}
if ([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT -or -not [Environment]::Is64BitProcess) {
throw 'Godot editor payload requires Windows x64.'
}
if (-not $Compiler) { throw 'No C compiler found. Install a Windows x64 C toolchain or pass -Compiler.' }
$Compiler = (Get-Command $Compiler -ErrorAction Stop).Source
$build = Join-Path $root '.build'
$CMake = (Get-Command $CMake -ErrorAction Stop).Source
$Python = (Get-Command $Python -ErrorAction Stop).Source
$build = Join-Path $root '.build/msvc'
$generated = Join-Path $root '.build/generated'
$output = Join-Path $root 'bin/win-x64'
New-Item -ItemType Directory -Path $build,$output -Force | Out-Null
New-Item -ItemType Directory -Path $generated,$output -Force | Out-Null
$utf8 = [Text.UTF8Encoding]::new($false)
$inputs = @('src/native.c','src/bridge.gd','vendor/gdextension_interface.h','vendor/provenance.json','build.ps1')
$fingerprint = 'agc.godot.editor.v1/windows/x86_64/c11/O2' + "`n"
$fingerprint += 'compiler:' + (Get-FileHash -Algorithm SHA256 -LiteralPath $Compiler).Hash.ToLowerInvariant() + "`n"
foreach ($inputPath in $inputs) { $fingerprint += $inputPath + ':' + (Get-FileHash -Algorithm SHA256 -LiteralPath (Join-Path $root $inputPath)).Hash.ToLowerInvariant() + "`n" }
$hasher = [Security.Cryptography.SHA256]::Create()
try { $buildId = 'sha256:' + ([BitConverter]::ToString($hasher.ComputeHash($utf8.GetBytes($fingerprint))).Replace('-','').ToLowerInvariant()) } finally { $hasher.Dispose() }
$existingDll = Join-Path $output 'agc_godot_editor.dll'
$existingMetadata = Join-Path $output 'metadata.json'
if ((Test-Path -LiteralPath $existingDll) -and (Test-Path -LiteralPath $existingMetadata)) {
try {
$existing = Get-Content -LiteralPath $existingMetadata -Raw | ConvertFrom-Json
$existingHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $existingDll).Hash.ToLowerInvariant()
if ($existing.protocol -eq 'agc.godot.editor.v1' -and $existing.buildId -eq $buildId -and $existing.sha256 -eq $existingHash -and $existing.entrySymbol -eq 'agc_godot_editor_init' -and $existing.platform -eq 'windows' -and $existing.arch -eq 'x86_64' -and $existing.minimumGodotVersion -eq '4.7') {
Write-Output "Native payload is current: $buildId"
$previousBytecode = $env:PYTHONDONTWRITEBYTECODE
try {
$env:PYTHONDONTWRITEBYTECODE = '1'
$dependency = & $Python -X utf8 (Join-Path $root 'prepare_dependencies.py')
if ($LASTEXITCODE -ne 0) { throw 'Pinned godot-cpp dependency verification failed.' }
& $CMake -S $root -B $build -G $Generator -A x64 "-DAGC_GODOT_CPP_SOURCE=$dependency" "-DAGC_GENERATED_DIR=$generated" "-DPython3_EXECUTABLE=$Python"
if ($LASTEXITCODE -ne 0) { throw 'Godot C++ configure failed; install Visual Studio C++ x64, CMake and Python.' }
$compilerRecords = @(Get-ChildItem -LiteralPath (Join-Path $build 'CMakeFiles') -Filter 'CMakeCXXCompiler.cmake' -Recurse -File)
if ($compilerRecords.Count -ne 1) { throw 'MSVC compiler identity is ambiguous.' }
$record = [IO.File]::ReadAllText($compilerRecords[0].FullName)
$compilerMatch = [regex]::Match($record, 'set\(CMAKE_CXX_COMPILER "([^"]+)"\)')
if (-not $compilerMatch.Success -or -not $record.Contains('set(CMAKE_CXX_COMPILER_ID "MSVC")')) { throw 'MSVC compiler identity was not verified.' }
$compiler = $compilerMatch.Groups[1].Value
$inputs = @('src/native.cpp','src/bridge.gd','CMakeLists.txt','build-profile.json','prepare_dependencies.py','vendor/provenance.json','vendor/LICENSE.txt','build.ps1')
$fingerprint = 'agc.godot.editor.v1/windows/x86_64/c++17/msvc/Release/static-crt' + "`n"
$fingerprint += 'compiler:' + (Get-FileHash -LiteralPath $compiler -Algorithm SHA256).Hash.ToLowerInvariant() + "`n"
$fingerprint += 'cmake:' + (Get-FileHash -LiteralPath $CMake -Algorithm SHA256).Hash.ToLowerInvariant() + "`n"
$fingerprint += 'configuration:' + (Get-FileHash -LiteralPath (Join-Path $build 'CMakeCache.txt') -Algorithm SHA256).Hash.ToLowerInvariant() + "`n"
$fingerprint += 'toolchain:' + (Get-FileHash -LiteralPath $compilerRecords[0].FullName -Algorithm SHA256).Hash.ToLowerInvariant() + "`n"
foreach ($relative in $inputs) { $fingerprint += $relative + ':' + (Get-FileHash -LiteralPath (Join-Path $root $relative) -Algorithm SHA256).Hash.ToLowerInvariant() + "`n" }
$hasher = [Security.Cryptography.SHA256]::Create()
try { $buildId = 'sha256:' + ([BitConverter]::ToString($hasher.ComputeHash($utf8.GetBytes($fingerprint))).Replace('-','').ToLowerInvariant()) } finally { $hasher.Dispose() }
$dll = Join-Path $output 'agc_godot_editor.dll'
$metadataPath = Join-Path $output 'metadata.json'
if ((Test-Path -LiteralPath $dll) -and (Test-Path -LiteralPath $metadataPath)) {
$existing = Get-Content -LiteralPath $metadataPath -Raw | ConvertFrom-Json
if ($existing.protocol -eq 'agc.godot.editor.v1' -and $existing.entrySymbol -eq 'agc_godot_editor_init' -and $existing.platform -eq 'windows' -and $existing.arch -eq 'x86_64' -and $existing.minimumGodotVersion -eq '4.7' -and $existing.buildId -eq $buildId -and $existing.sha256 -eq (Get-FileHash -LiteralPath $dll -Algorithm SHA256).Hash.ToLowerInvariant()) {
Write-Output "Native C++ payload is current: $buildId"
return
}
} catch { Write-Verbose 'Existing metadata could not be verified; rebuilding.' }
}
$script = [IO.File]::ReadAllBytes((Join-Path $root 'src/bridge.gd'))
$embedded = [Text.StringBuilder]::new()
[void]$embedded.AppendLine('/* Generated from src/bridge.gd; never reads a project-side script. */')
[void]$embedded.AppendLine('#define AGC_BUILD_ID "' + $buildId + '"')
[void]$embedded.AppendLine('static const unsigned char AGC_EMBEDDED_BRIDGE[] = {')
for ($index = 0; $index -lt $script.Length; $index += 32) {
$last = [Math]::Min($index + 31, $script.Length - 1)
[void]$embedded.AppendLine(($script[$index..$last] -join ',') + ',')
}
[void]$embedded.AppendLine('0};')
[IO.File]::WriteAllText((Join-Path $build 'embedded_bridge.h'), $embedded.ToString(), $utf8)
$previousTemp = $env:TEMP
$previousTmp = $env:TMP
$previousLocation = Get-Location
try {
$env:TEMP = $build
$env:TMP = $build
Set-Location -LiteralPath $build
$source = Join-Path $root 'src/native.c'
$vendor = Join-Path $root 'vendor'
$temporaryDll = Join-Path $build 'agc_godot_editor.dll'
$compilerName = [IO.Path]::GetFileName($Compiler).ToLowerInvariant()
if ($compilerName -eq 'cl.exe') {
& $Compiler /nologo /std:c11 /O2 /W4 /WX /LD /D_CRT_SECURE_NO_WARNINGS "/I$vendor" "/I$build" $source "/Fe:$temporaryDll" /link /Brepro
} else {
$flags = @('-std=c11','-O2','-Wall','-Wextra','-Werror','-shared')
if ($compilerName -eq 'gcc.exe') { $flags += @('-static-libgcc','-Wl,--no-insert-timestamp') }
& $Compiler @flags -I $vendor -I $build $source -o $temporaryDll
}
if ($LASTEXITCODE -ne 0) { throw "Native compiler exited with $LASTEXITCODE" }
$dll = Join-Path $output 'agc_godot_editor.dll'
Copy-Item -LiteralPath $temporaryDll -Destination $dll -Force
$script = [IO.File]::ReadAllBytes((Join-Path $root 'src/bridge.gd'))
$embedded = [Text.StringBuilder]::new()
[void]$embedded.AppendLine('#define AGC_BUILD_ID "' + $buildId + '"')
[void]$embedded.AppendLine('static const unsigned char AGC_EMBEDDED_BRIDGE[] = {')
for ($index = 0; $index -lt $script.Length; $index += 32) {
$last = [Math]::Min($index + 31, $script.Length - 1)
[void]$embedded.AppendLine(($script[$index..$last] -join ',') + ',')
}
[void]$embedded.AppendLine('0};')
$header = Join-Path $generated 'embedded_bridge.h'
if (-not (Test-Path -LiteralPath $header) -or [IO.File]::ReadAllText($header) -ne $embedded.ToString()) { [IO.File]::WriteAllText($header, $embedded.ToString(), $utf8) }
& $CMake --build $build --config Release --target agc_godot_editor --parallel $Jobs
if ($LASTEXITCODE -ne 0) { throw 'Godot C++ MSVC build failed.' }
Copy-Item -LiteralPath (Join-Path $build 'payload/agc_godot_editor.dll') -Destination $dll -Force
$metadata = [ordered]@{
protocol = 'agc.godot.editor.v1'
buildId = $buildId
sha256 = (Get-FileHash -Algorithm SHA256 -LiteralPath $dll).Hash.ToLowerInvariant()
platform = 'windows'
arch = 'x86_64'
entrySymbol = 'agc_godot_editor_init'
minimumGodotVersion = '4.7'
protocol = 'agc.godot.editor.v1'; buildId = $buildId
sha256 = (Get-FileHash -LiteralPath $dll -Algorithm SHA256).Hash.ToLowerInvariant()
platform = 'windows'; arch = 'x86_64'; entrySymbol = 'agc_godot_editor_init'; minimumGodotVersion = '4.7'
}
[IO.File]::WriteAllText((Join-Path $output 'metadata.json'), ($metadata | ConvertTo-Json) + "`n", $utf8)
Write-Output "Built $dll"
Write-Output "Build identity: $buildId"
[IO.File]::WriteAllText($metadataPath, ($metadata | ConvertTo-Json) + "`n", $utf8)
Write-Output "Built C++ payload: $buildId"
} finally {
Set-Location -LiteralPath $previousLocation
$env:TEMP = $previousTemp
$env:TMP = $previousTmp
$env:PYTHONDONTWRITEBYTECODE = $previousBytecode
}
@@ -0,0 +1,91 @@
"""Prepare only the pinned official SDK; never execute an unchecked archive."""
import hashlib
import json
import os
from pathlib import Path, PurePosixPath
import re
import stat
import sys
import tempfile
import urllib.request
import zipfile
def plain(path):
for item in (path, *path.parents):
if item.exists() or item.is_symlink():
info = item.lstat()
if item.is_symlink() or getattr(info, "st_file_attributes", 0) & 0x400:
raise ValueError(f"Dependency path cannot contain links: {item}")
def digest(data):
return hashlib.sha256(data).hexdigest()
def prepare(root):
provenance = json.loads((root / "vendor/provenance.json").read_text(encoding="utf-8"))
commit = provenance["commit"]
expected = provenance["archiveSha256"]
if not re.fullmatch(r"[a-f0-9]{40}", commit) or not re.fullmatch(r"[a-f0-9]{64}", expected):
raise ValueError("Invalid pinned dependency identity")
url = f"https://codeload.github.com/godotengine/godot-cpp/zip/{commit}"
if provenance["archiveUrl"] != url:
raise ValueError("Dependency URL must identify the pinned official repository")
cache = root / ".build/dependencies"
plain(cache)
cache.mkdir(parents=True, exist_ok=True)
archive = cache / f"{commit}.zip"
plain(archive)
if not archive.exists():
with urllib.request.urlopen(url, timeout=60) as response:
data = response.read(32 * 1024 * 1024 + 1)
if len(data) > 32 * 1024 * 1024 or digest(data) != expected:
raise ValueError("Official godot-cpp archive SHA256 mismatch")
with tempfile.NamedTemporaryFile(dir=cache, delete=False) as output:
output.write(data)
temporary = Path(output.name)
os.replace(temporary, archive)
if digest(archive.read_bytes()) != expected:
raise ValueError("Cached godot-cpp archive SHA256 mismatch; cache was preserved")
source = cache / f"godot-cpp-{commit}"
plain(source)
source.mkdir(exist_ok=True)
expected_files = set()
with zipfile.ZipFile(archive) as bundle:
for entry in bundle.infolist():
relative = PurePosixPath(entry.filename)
if (not relative.parts or relative.parts[0] != source.name or relative.is_absolute()
or ".." in relative.parts or "\\" in entry.filename or ":" in entry.filename):
raise ValueError("Unsafe dependency archive entry")
if stat.S_ISLNK(entry.external_attr >> 16):
raise ValueError("Dependency archive cannot contain symbolic links")
if entry.is_dir():
continue
target = source.joinpath(*relative.parts[1:])
if target in expected_files:
raise ValueError("Duplicate dependency archive entry")
plain(target)
expected_files.add(target)
content = bundle.read(entry)
if target.exists():
if not target.is_file() or target.read_bytes() != content:
raise ValueError(f"Modified godot-cpp source cache was preserved: {target}")
else:
target.parent.mkdir(parents=True, exist_ok=True)
target.write_bytes(content)
for item in source.rglob("*"):
plain(item)
if item.is_file() and item not in expected_files:
raise ValueError(f"Unexpected dependency source cache entry: {item}")
if (source / "LICENSE.md").read_text(encoding="utf-8") != (root / "vendor/LICENSE.txt").read_text(encoding="utf-8"):
raise ValueError("Packaged godot-cpp license differs from pinned upstream license")
return source
if __name__ == "__main__":
try:
print(prepare(Path(__file__).resolve().parent))
except (OSError, ValueError, KeyError, zipfile.BadZipFile) as error:
print(str(error), file=sys.stderr)
sys.exit(1)
@@ -1,258 +0,0 @@
#define WIN32_LEAN_AND_MEAN
#include <windows.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <stddef.h>
#include <string.h>
#include <wchar.h>
#include "gdextension_interface.h"
#include "embedded_bridge.h"
/* Windows x64 ABI storage is deliberately oversized and 16-byte aligned.
* Explicit C11 alignment also works with MSVC C, which lacks max_align_t.
* Objects are constructed/destructed solely through the official interface. */
typedef struct { _Alignas(16) unsigned char bytes[128]; } Storage;
static GDExtensionInterfacePrintWarning api_warning;
static GDExtensionInterfaceVariantCall api_call;
static GDExtensionInterfaceVariantDestroy api_destroy;
static GDExtensionInterfaceVariantGetType api_type;
static GDExtensionInterfaceGlobalGetSingleton api_singleton;
static GDExtensionInterfaceStringNameNewWithLatin1Chars api_name;
static GDExtensionInterfaceStringNewWithUtf8Chars api_string;
static GDExtensionInterfaceStringToUtf8Chars api_utf8;
static GDExtensionVariantFromTypeConstructorFunc from_object, from_string, from_name;
static GDExtensionTypeFromVariantConstructorFunc to_int, to_string;
static GDExtensionPtrDestructor destroy_name, destroy_string;
static Storage retained_script, retained_node;
static int script_live, node_live, started;
static void report_failure(const char *operation, int code) {
char message[256];
snprintf(message, sizeof(message), "AGC Godot editor bridge: %s failed (%d).", operation, code);
if (api_warning) api_warning(message, "agc_godot_editor", "native.c", 0, 0);
}
static void name_variant(Storage *out, const char *text) {
Storage name;
api_name(&name, text, 0);
from_name(out, &name);
destroy_name(&name);
}
static void string_variant(Storage *out, const char *text) {
Storage string;
api_string(&string, text);
from_string(out, &string);
destroy_string(&string);
}
static int invoke(Storage *receiver, const char *method,
const GDExtensionConstVariantPtr *arguments, int count, Storage *out) {
Storage name;
GDExtensionCallError error = { GDEXTENSION_CALL_OK, 0, 0 };
api_name(&name, method, 0);
api_call(receiver, &name, arguments, count, out, &error);
destroy_name(&name);
if (error.error != GDEXTENSION_CALL_OK) {
report_failure(method, (int)error.error);
return 0;
}
return 1;
}
static int singleton_variant(Storage *out, const char *text) {
Storage name;
api_name(&name, text, 0);
GDExtensionObjectPtr object = api_singleton(&name);
destroy_name(&name);
if (!object) return 0;
from_object(out, &object);
return 1;
}
static char *variant_utf8(Storage *value) {
if (api_type(value) != GDEXTENSION_VARIANT_TYPE_STRING) return NULL;
Storage string;
to_string(&string, value);
GDExtensionInt length = api_utf8(&string, NULL, 0);
char *text = NULL;
if (length >= 0 && length < 131072) {
text = (char *)malloc((size_t)length + 1);
if (text) {
api_utf8(&string, text, length);
text[length] = '\0';
}
}
destroy_string(&string);
return text;
}
static int plain_directory(const wchar_t *path) {
DWORD attrs = GetFileAttributesW(path);
return attrs != INVALID_FILE_ATTRIBUTES && (attrs & FILE_ATTRIBUTE_DIRECTORY) &&
!(attrs & FILE_ATTRIBUTE_REPARSE_POINT);
}
static int ensure_cache_directory(wchar_t *path, size_t capacity, const wchar_t *part) {
size_t length = wcslen(path), addition = wcslen(part);
if (length + addition + 2 >= capacity) return 0;
if (length && path[length - 1] != L'\\') path[length++] = L'\\';
memcpy(path + length, part, (addition + 1) * sizeof(wchar_t));
if (!CreateDirectoryW(path, NULL) && GetLastError() != ERROR_ALREADY_EXISTS) return 0;
return plain_directory(path);
}
static char *prepare_cache_path(void) {
Storage settings, argument, result;
if (!singleton_variant(&settings, "ProjectSettings")) return NULL;
string_variant(&argument, "res://");
const GDExtensionConstVariantPtr args[] = { &argument };
int ok = invoke(&settings, "globalize_path", args, 1, &result);
char *root_utf8 = ok ? variant_utf8(&result) : NULL;
api_destroy(&result);
api_destroy(&argument);
api_destroy(&settings);
if (!root_utf8) return NULL;
wchar_t path[32768];
int length = MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, root_utf8, -1, path, 32768);
free(root_utf8);
if (length < 4 || path[1] != L':') return NULL;
for (int index = 0; index < length; ++index) if (path[index] == L'/') path[index] = L'\\';
/* Reject links/junctions in every existing directory, including project ancestors. */
for (int index = 3; index < length; ++index) {
if (path[index] != L'\\' && path[index] != L'\0') continue;
wchar_t saved = path[index];
path[index] = L'\0';
int plain = plain_directory(path);
path[index] = saved;
if (!plain) return NULL;
}
if (!ensure_cache_directory(path, 32768, L".godot") ||
!ensure_cache_directory(path, 32768, L"agc")) return NULL;
wchar_t suffix[96];
swprintf(suffix, 96, L"\\editor-bridge-%lu.json", (unsigned long)GetCurrentProcessId());
if (wcslen(path) + wcslen(suffix) + 1 >= 32768) return NULL;
wcscat(path, suffix);
DWORD attrs = GetFileAttributesW(path);
if (attrs != INVALID_FILE_ATTRIBUTES && (attrs & (FILE_ATTRIBUTE_REPARSE_POINT | FILE_ATTRIBUTE_DIRECTORY))) return NULL;
int size = WideCharToMultiByte(CP_UTF8, WC_ERR_INVALID_CHARS, path, -1, NULL, 0, NULL, NULL);
if (size <= 0) return NULL;
char *cache = (char *)malloc((size_t)size);
if (cache) WideCharToMultiByte(CP_UTF8, WC_ERR_INVALID_CHARS, path, -1, cache, size, NULL, NULL);
return cache;
}
static void release_references(void) {
if (node_live) { api_destroy(&retained_node); node_live = 0; }
if (script_live) { api_destroy(&retained_script); script_live = 0; }
}
static int schedule_bridge(void) {
FILETIME creation, exit_time, kernel, user;
if (!GetProcessTimes(GetCurrentProcess(), &creation, &exit_time, &kernel, &user)) return 0;
ULARGE_INTEGER timestamp;
timestamp.LowPart = creation.dwLowDateTime;
timestamp.HighPart = creation.dwHighDateTime;
char started_file_time[32];
snprintf(started_file_time, sizeof(started_file_time), "%llu", (unsigned long long)timestamp.QuadPart);
char *cache_path = prepare_cache_path();
if (!cache_path) { report_failure("session_cache_path", 0); return 0; }
Storage classdb, class_arg, result, source;
if (!singleton_variant(&classdb, "ClassDB")) { free(cache_path); return 0; }
name_variant(&class_arg, "GDScript");
const GDExtensionConstVariantPtr class_args[] = { &class_arg };
int ok = invoke(&classdb, "instantiate", class_args, 1, &retained_script);
script_live = 1;
api_destroy(&class_arg);
api_destroy(&classdb);
if (!ok || api_type(&retained_script) != GDEXTENSION_VARIANT_TYPE_OBJECT) { free(cache_path); return 0; }
string_variant(&source, (const char *)AGC_EMBEDDED_BRIDGE);
const GDExtensionConstVariantPtr source_args[] = { &source };
ok = invoke(&retained_script, "set_source_code", source_args, 1, &result);
api_destroy(&result);
api_destroy(&source);
if (!ok) { free(cache_path); return 0; }
ok = invoke(&retained_script, "reload", NULL, 0, &result);
int64_t reload_error = -1;
if (ok && api_type(&result) == GDEXTENSION_VARIANT_TYPE_INT) to_int(&reload_error, &result);
api_destroy(&result);
if (!ok || reload_error != 0) { free(cache_path); report_failure("bridge_compile", (int)reload_error); return 0; }
ok = invoke(&retained_script, "new", NULL, 0, &retained_node);
node_live = 1;
if (!ok || api_type(&retained_node) != GDEXTENSION_VARIANT_TYPE_OBJECT) { free(cache_path); return 0; }
Storage method, build, process_identity, cache;
name_variant(&method, "bootstrap");
string_variant(&build, AGC_BUILD_ID);
string_variant(&process_identity, started_file_time);
string_variant(&cache, cache_path);
free(cache_path);
const GDExtensionConstVariantPtr deferred[] = { &method, &build, &process_identity, &cache };
ok = invoke(&retained_node, "call_deferred", deferred, 4, &result);
api_destroy(&result);
api_destroy(&method);
api_destroy(&build);
api_destroy(&process_identity);
api_destroy(&cache);
return ok;
}
static void initialize_bridge(void *userdata, GDExtensionInitializationLevel level) {
(void)userdata;
if (level != GDEXTENSION_INITIALIZATION_EDITOR || started) return;
started = 1;
if (!schedule_bridge()) release_references();
}
static void deinitialize_bridge(void *userdata, GDExtensionInitializationLevel level) {
(void)userdata;
if (level != GDEXTENSION_INITIALIZATION_EDITOR) return;
if (node_live && api_type(&retained_node) == GDEXTENSION_VARIANT_TYPE_OBJECT) {
Storage returned;
invoke(&retained_node, "native_deinitialize", NULL, 0, &returned);
api_destroy(&returned);
}
release_references();
}
__declspec(dllexport) GDExtensionBool agc_godot_editor_init(
GDExtensionInterfaceGetProcAddress get_proc_address,
GDExtensionClassLibraryPtr library,
GDExtensionInitialization *initialization) {
(void)library;
if (!get_proc_address || !initialization) return 0;
#define LOAD(variable, type, symbol) do { \
GDExtensionInterfaceFunctionPtr raw_function = get_proc_address(symbol); \
_Static_assert(sizeof(type) == sizeof(raw_function), "Windows function pointer ABI mismatch"); \
memcpy(&(variable), &raw_function, sizeof(variable)); \
if (!variable) return 0; \
} while (0)
LOAD(api_warning, GDExtensionInterfacePrintWarning, "print_warning");
LOAD(api_call, GDExtensionInterfaceVariantCall, "variant_call");
LOAD(api_destroy, GDExtensionInterfaceVariantDestroy, "variant_destroy");
LOAD(api_type, GDExtensionInterfaceVariantGetType, "variant_get_type");
LOAD(api_singleton, GDExtensionInterfaceGlobalGetSingleton, "global_get_singleton");
LOAD(api_name, GDExtensionInterfaceStringNameNewWithLatin1Chars, "string_name_new_with_latin1_chars");
LOAD(api_string, GDExtensionInterfaceStringNewWithUtf8Chars, "string_new_with_utf8_chars");
LOAD(api_utf8, GDExtensionInterfaceStringToUtf8Chars, "string_to_utf8_chars");
GDExtensionInterfaceGetVariantFromTypeConstructor get_from;
GDExtensionInterfaceGetVariantToTypeConstructor get_to;
GDExtensionInterfaceVariantGetPtrDestructor get_destructor;
LOAD(get_from, GDExtensionInterfaceGetVariantFromTypeConstructor, "get_variant_from_type_constructor");
LOAD(get_to, GDExtensionInterfaceGetVariantToTypeConstructor, "get_variant_to_type_constructor");
LOAD(get_destructor, GDExtensionInterfaceVariantGetPtrDestructor, "variant_get_ptr_destructor");
#undef LOAD
from_object = get_from(GDEXTENSION_VARIANT_TYPE_OBJECT);
from_string = get_from(GDEXTENSION_VARIANT_TYPE_STRING);
from_name = get_from(GDEXTENSION_VARIANT_TYPE_STRING_NAME);
to_int = get_to(GDEXTENSION_VARIANT_TYPE_INT);
to_string = get_to(GDEXTENSION_VARIANT_TYPE_STRING);
destroy_name = get_destructor(GDEXTENSION_VARIANT_TYPE_STRING_NAME);
destroy_string = get_destructor(GDEXTENSION_VARIANT_TYPE_STRING);
if (!from_object || !from_string || !from_name || !to_int || !to_string || !destroy_name || !destroy_string) return 0;
initialization->minimum_initialization_level = GDEXTENSION_INITIALIZATION_EDITOR;
initialization->userdata = NULL;
initialization->initialize = initialize_bridge;
initialization->deinitialize = deinitialize_bridge;
return 1;
}
@@ -0,0 +1,159 @@
#ifndef WIN32_LEAN_AND_MEAN
#define WIN32_LEAN_AND_MEAN
#endif
#ifndef NOMINMAX
#define NOMINMAX
#endif
#include <windows.h>
#include <godot_cpp/classes/gd_script.hpp>
#include <godot_cpp/classes/node.hpp>
#include <godot_cpp/classes/project_settings.hpp>
#include <godot_cpp/core/class_db.hpp>
#include <godot_cpp/core/object.hpp>
#include <godot_cpp/godot.hpp>
#include <godot_cpp/variant/utility_functions.hpp>
#include <string>
#include "embedded_bridge.h"
namespace {
using namespace godot;
// Godot 值的析构必须发生在官方绑定终止之前,不能依赖 DLL 静态析构顺序。
struct BridgeState {
Ref<GDScript> script;
uint64_t node_id = 0;
};
BridgeState *bridge = nullptr;
bool plain_directory(const std::wstring &path) {
const DWORD attributes = GetFileAttributesW(path.c_str());
return attributes != INVALID_FILE_ATTRIBUTES &&
(attributes & FILE_ATTRIBUTE_DIRECTORY) &&
!(attributes & FILE_ATTRIBUTE_REPARSE_POINT);
}
bool append_cache_directory(std::wstring &path, const wchar_t *part) {
if (path.back() != L'\\')
path += L'\\';
path += part;
if (!CreateDirectoryW(path.c_str(), nullptr) &&
GetLastError() != ERROR_ALREADY_EXISTS)
return false;
return plain_directory(path);
}
String session_cache_path() {
const String project_root =
ProjectSettings::get_singleton()->globalize_path("res://");
const Char16String utf16 = project_root.utf16();
static_assert(sizeof(wchar_t) == sizeof(char16_t),
"Windows UTF-16 path required");
std::wstring path(reinterpret_cast<const wchar_t *>(utf16.get_data()),
utf16.length());
if (path.size() < 3 || path.size() > 32000 || path[1] != L':')
return {};
for (wchar_t &character : path)
if (character == L'/')
character = L'\\';
// 项目根及其所有祖先都须为普通本地目录,不经过链接或 junction。
for (size_t index = 3; index <= path.size(); ++index) {
if (index == path.size() || path[index] == L'\\') {
if (!plain_directory(path.substr(0, index)))
return {};
}
}
if (!append_cache_directory(path, L".godot") ||
!append_cache_directory(path, L"agc"))
return {};
path +=
L"\\editor-bridge-" + std::to_wstring(GetCurrentProcessId()) + L".json";
const DWORD attributes = GetFileAttributesW(path.c_str());
if (attributes != INVALID_FILE_ATTRIBUTES &&
(attributes & (FILE_ATTRIBUTE_REPARSE_POINT | FILE_ATTRIBUTE_DIRECTORY)))
return {};
return String::utf16(reinterpret_cast<const char16_t *>(path.c_str()));
}
bool schedule_bridge() {
FILETIME creation, exit_time, kernel, user;
if (!GetProcessTimes(GetCurrentProcess(), &creation, &exit_time, &kernel,
&user))
return false;
ULARGE_INTEGER timestamp;
timestamp.LowPart = creation.dwLowDateTime;
timestamp.HighPart = creation.dwHighDateTime;
const String started = String::num_uint64(timestamp.QuadPart);
const String cache = session_cache_path();
if (cache.is_empty())
return false;
bridge->script.instantiate();
bridge->script->set_source_code(
String::utf8(reinterpret_cast<const char *>(AGC_EMBEDDED_BRIDGE)));
if (bridge->script->reload() != OK)
return false;
const Variant instance = bridge->script->call("new");
Node *node = Object::cast_to<Node>(static_cast<Object *>(instance));
if (!node)
return false;
bridge->node_id = node->get_instance_id();
node->call_deferred("bootstrap", AGC_BUILD_ID, started, cache);
return true;
}
void initialize_bridge(ModuleInitializationLevel level) {
if (level != MODULE_INITIALIZATION_LEVEL_EDITOR || bridge)
return;
bridge = new BridgeState;
if (!schedule_bridge()) {
UtilityFunctions::push_warning(
"AGC Godot editor bridge initialization failed");
delete bridge;
bridge = nullptr;
}
}
void deinitialize_bridge(ModuleInitializationLevel level) {
if (level != MODULE_INITIALIZATION_LEVEL_EDITOR)
return;
if (bridge) {
// bootstrap 失败或编辑器退出时 Node 可能先被销毁,不能保留悬空指针。
if (Object *node = ObjectDB::get_instance(bridge->node_id)) {
const GDExtensionObjectPtr owner = node->_owner;
node->call("native_deinitialize");
// GDScript 的 queue_free 晚于 DLL 卸载;只移除 C++ 包装的回调,不销毁引擎
// Node。
internal::gdextension_interface_object_free_instance_binding(
owner, internal::token);
}
// 脚本仍可能被当前 GDScript 调用栈引用。用 Variant 保活引擎对象,先释放 C++
// Ref, 再解除 DLL 内的包装回调;保活值在本回调返回前销毁。
const Variant script_lifetime = bridge->script;
const GDExtensionObjectPtr script_owner =
bridge->script.is_valid() ? bridge->script->_owner : nullptr;
bridge->script.unref();
if (script_owner)
internal::gdextension_interface_object_free_instance_binding(
script_owner, internal::token);
delete bridge;
bridge = nullptr;
}
// 晚加载扩展只收到 EDITOR 生命周期,官方单例包装清理原本位于 CORE 终止阶段。
ClassDB::deinitialize(GDEXTENSION_INITIALIZATION_CORE);
}
} // namespace
extern "C" GDExtensionBool GDE_EXPORT
agc_godot_editor_init(GDExtensionInterfaceGetProcAddress get_proc_address,
GDExtensionClassLibraryPtr library,
GDExtensionInitialization *initialization) {
godot::GDExtensionBinding::InitObject init(get_proc_address, library,
initialization);
init.register_initializer(initialize_bridge);
init.register_terminator(deinitialize_bridge);
init.set_minimum_library_initialization_level(
godot::MODULE_INITIALIZATION_LEVEL_EDITOR);
return init.init();
}
@@ -0,0 +1,73 @@
import hashlib
import importlib.util
import json
from pathlib import Path
import sys
import tempfile
import unittest
import zipfile
from unittest.mock import patch
sys.dont_write_bytecode = True
spec = importlib.util.spec_from_file_location("dependencies", Path(__file__).parents[1] / "prepare_dependencies.py")
dependencies = importlib.util.module_from_spec(spec)
spec.loader.exec_module(dependencies)
class DependencyTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.root = Path(self.temp.name)
self.commit = "a" * 40
self.name = f"godot-cpp-{self.commit}"
self.archive = self.root / ".build/dependencies" / f"{self.commit}.zip"
self.archive.parent.mkdir(parents=True)
(self.root / "vendor").mkdir()
(self.root / "vendor/LICENSE.txt").write_bytes(b"MIT\r\n")
self.write_archive({"LICENSE.md": b"MIT\n", "src/core.cpp": b"verified"})
def write_archive(self, entries):
with zipfile.ZipFile(self.archive, "w") as output:
for name, content in entries.items():
output.writestr(f"{self.name}/{name}", content)
provenance = {"commit": self.commit,
"archiveUrl": f"https://codeload.github.com/godotengine/godot-cpp/zip/{self.commit}",
"archiveSha256": hashlib.sha256(self.archive.read_bytes()).hexdigest()}
(self.root / "vendor/provenance.json").write_text(json.dumps(provenance), encoding="utf-8")
def test_verified_archive_is_reusable_offline(self):
with patch("urllib.request.urlopen", side_effect=AssertionError("Unexpected network")):
source = dependencies.prepare(self.root)
self.assertEqual(source, dependencies.prepare(self.root))
self.assertEqual((source / "src/core.cpp").read_bytes(), b"verified")
def test_corrupt_archive_is_rejected_and_preserved(self):
self.archive.write_bytes(b"corrupt")
with self.assertRaisesRegex(ValueError, "SHA256 mismatch"):
dependencies.prepare(self.root)
self.assertEqual(self.archive.read_bytes(), b"corrupt")
def test_modified_source_is_rejected_even_with_valid_archive(self):
source = dependencies.prepare(self.root)
(source / "src/core.cpp").write_bytes(b"modified")
with self.assertRaisesRegex(ValueError, "Modified godot-cpp"):
dependencies.prepare(self.root)
self.assertEqual((source / "src/core.cpp").read_bytes(), b"modified")
def test_extra_source_is_rejected(self):
source = dependencies.prepare(self.root)
(source / "extra.cpp").write_text("unexpected", encoding="utf-8")
with self.assertRaisesRegex(ValueError, "Unexpected dependency"):
dependencies.prepare(self.root)
def test_archive_paths_cannot_escape_on_windows_or_posix(self):
for name in ["../../escaped", "C:\\escaped", "..\\escaped"]:
with self.subTest(name=name):
self.write_archive({name: b"unexpected"})
with self.assertRaisesRegex(ValueError, "Unsafe dependency"):
dependencies.prepare(self.root)
if __name__ == "__main__":
unittest.main()
@@ -1,5 +1,6 @@
Copyright (c) 2014-present Godot Engine contributors (see AUTHORS.md).
Copyright (c) 2007-2014 Juan Linietsky, Ariel Manzur.
# MIT License
Copyright (c) 2017-present Godot Engine contributors.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal

Some files were not shown because too many files have changed in this diff Show More