Merge remote-tracking branch 'origin/master' into feat/pricing-plan
Project CI / Backend tests (pull_request) Failing after 1m51s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 3m10s
Project CI / Frontend tests (pull_request) Failing after 1m31s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 4m59s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 5m50s
Project CI / AI game creator shell web tests (pull_request) Successful in 2m38s
Project CI / Repository checks (pull_request) Failing after 4m27s
Project CI / Native shell tests (pull_request) Successful in 7m43s

# Conflicts:
#	docs/project-memory/shared-memory/decision-log.md
#	server-rs/crates/module-runtime/src/lib.rs
This commit is contained in:
2026-10-03 20:32:40 +08:00
273 changed files with 17327 additions and 5284 deletions
+7
View File
@@ -49,6 +49,7 @@ import type {
AdminLoginResponse,
AdminMeResponse,
AdminOverviewResponse,
AdminPaymentOrderListResponse,
AdminProjectSnapshotChannelsResponse,
AdminProjectSnapshotListQuery,
AdminProjectSnapshotListResponse,
@@ -825,6 +826,12 @@ export function listAdminRechargeOrders(
);
}
export function listAdminPaymentOrders(token: string) {
return request<AdminPaymentOrderListResponse>('/admin/api/payment/orders', {
token,
});
}
export function getAdminUserDetail(token: string, query: AdminUserDetailQuery) {
return request<AdminUserDetailResponse>(
`/admin/api/profile/users/detail${buildAdminUserDetailQuery(query)}`,
+33
View File
@@ -1119,6 +1119,39 @@ export interface AdminWechatPaymentCheckPayload {
knownRefundsRefreshed: number;
}
export interface AdminPaymentOrderEntry {
orderId: string;
appId: string;
ownerUserId: string;
merchantOrderId: string;
title: string;
amountCents: number;
currency: string;
provider: string;
providerTradeNo: string | null;
status: string;
createdAt: string;
expiresAt: string;
paidAt: string | null;
}
export interface AdminPaymentOrderListResponse {
entries: AdminPaymentOrderEntry[];
webhooks: AdminPaymentWebhookEntry[];
}
export interface AdminPaymentWebhookEntry {
deliveryId: string;
orderId: string;
appId: string;
callbackUrl: string;
status: string;
attemptCount: number;
availableAt: string;
lastErrorMessage: string | null;
updatedAt: string;
}
export interface AdminRechargeRefundPreviewResponse {
order: AdminRechargeOrderEntryPayload;
paymentCheck: AdminWechatPaymentCheckPayload;
+7
View File
@@ -38,6 +38,7 @@ import { AdminInviteCodePage } from '../pages/AdminInviteCodePage';
import { AdminLoginPage } from '../pages/AdminLoginPage';
import { AdminMembershipPlanPage } from '../pages/AdminMembershipPlanPage';
import { AdminOverviewPage } from '../pages/AdminOverviewPage';
import { AdminPaymentOrderPage } from '../pages/AdminPaymentOrderPage';
import { AdminProfileWalletConfigPage } from '../pages/AdminProfileWalletConfigPage';
import { AdminProjectSnapshotsPage } from '../pages/AdminProjectSnapshotsPage';
import { AdminRechargeOrderPage } from '../pages/AdminRechargeOrderPage';
@@ -311,6 +312,12 @@ export function AdminApp() {
onUnauthorized={handleUnauthorized}
/>
) : null}
{activeRouteId === 'payment-orders' ? (
<AdminPaymentOrderPage
token={token}
onUnauthorized={handleUnauthorized}
/>
) : null}
{activeRouteId === 'editor-generation-pricing' ? (
<AdminEditorGenerationPricingPage
token={token}
+1
View File
@@ -52,6 +52,7 @@ const routeIcons = {
'recharge-products': BadgeDollarSign,
'membership-plans': ShieldCheck,
'recharge-orders': ReceiptText,
'payment-orders': ReceiptText,
'editor-generation-pricing': Coins,
'editor-showcase': Star,
'game-distribution': Gamepad2,
+2
View File
@@ -15,6 +15,7 @@ export type AdminRouteId =
| 'recharge-products'
| 'membership-plans'
| 'recharge-orders'
| 'payment-orders'
| 'editor-generation-pricing'
| 'editor-showcase'
| 'game-distribution'
@@ -55,6 +56,7 @@ export const adminRoutes: AdminRouteDefinition[] = [
{ id: 'recharge-products', label: '充值商品', hash: '#recharge-products' },
{ id: 'membership-plans', label: '会员档位', hash: '#membership-plans' },
{ id: 'recharge-orders', label: '充值管理', hash: '#recharge-orders' },
{ id: 'payment-orders', label: '支付订单', hash: '#payment-orders' },
{
id: 'editor-generation-pricing',
label: '模型定价',
@@ -0,0 +1,198 @@
import {
AdminEmptyState,
AdminListPanel,
AdminPage,
AdminPageHeading,
AdminStatusPill,
} from '@genarrative/shared/components';
import { useCallback, useEffect, useState } from 'react';
import {
formatAdminApiError,
listAdminPaymentOrders,
} from '../api/adminApiClient';
import type {
AdminPaymentOrderEntry,
AdminPaymentWebhookEntry,
} from '../api/adminApiTypes';
import { handlePageError } from './pageUtils';
type AdminPaymentOrderPageProps = {
token: string;
onUnauthorized: (message?: string) => void;
};
function statusTone(status: string): 'ok' | 'pending' | 'error' {
if (status === 'paid') return 'ok';
if (status === 'closed' || status === 'expired') return 'error';
return 'pending';
}
export function AdminPaymentOrderPage({
token,
onUnauthorized,
}: AdminPaymentOrderPageProps) {
const [entries, setEntries] = useState<AdminPaymentOrderEntry[]>([]);
const [webhooks, setWebhooks] = useState<AdminPaymentWebhookEntry[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState('');
const load = useCallback(() => {
setIsLoading(true);
setError('');
void listAdminPaymentOrders(token)
.then((response) => {
setEntries(response.entries ?? []);
setWebhooks(response.webhooks ?? []);
})
.catch((nextError: unknown) => {
handlePageError(nextError, onUnauthorized, (message) =>
setError(message || formatAdminApiError(nextError)),
);
})
.finally(() => setIsLoading(false));
}, [onUnauthorized, token]);
useEffect(() => {
load();
}, [load]);
return (
<AdminPage wide>
<AdminPageHeading
title="支付订单"
description="平台统一收款的外部产品订单与到账状态。"
actions={
<button
className="admin-button admin-button--secondary"
type="button"
onClick={load}
>
刷新
</button>
}
/>
<AdminListPanel
title="外部回调投递"
count={`${webhooks.length} 条`}
busy={isLoading}
emptyText={<AdminEmptyState>暂无外部回调记录。</AdminEmptyState>}
columns={[
{
key: 'delivery',
label: '投递',
render: (entry) => (
<>
<strong>{entry.deliveryId}</strong>
<small className="admin-muted-block">{entry.orderId}</small>
</>
),
},
{ key: 'app', label: '应用', render: (entry) => entry.appId },
{
key: 'callback',
label: '回调地址',
render: (entry) => entry.callbackUrl,
},
{
key: 'status',
label: '状态',
render: (entry) => (
<AdminStatusPill
tone={
entry.status === 'delivered'
? 'ok'
: entry.status === 'failed'
? 'error'
: 'pending'
}
>
{entry.status}
</AdminStatusPill>
),
},
{
key: 'attempts',
label: '尝试次数',
render: (entry) => entry.attemptCount,
},
{
key: 'error',
label: '最近错误',
render: (entry) => entry.lastErrorMessage ?? '-',
},
{
key: 'updated',
label: '更新时间',
render: (entry) => entry.updatedAt,
},
]}
rows={webhooks}
rowKey={(entry) => entry.deliveryId}
/>
{error ? (
<div className="admin-alert admin-alert--error">{error}</div>
) : null}
<AdminListPanel
title="订单记录"
count={`${entries.length} 条`}
busy={isLoading}
emptyText={
<AdminEmptyState>
暂无支付订单,外部产品创建订单后会显示在这里。
</AdminEmptyState>
}
columns={[
{
key: 'order',
label: '订单',
render: (entry) => (
<>
<strong>{entry.merchantOrderId}</strong>
<small className="admin-muted-block">{entry.orderId}</small>
</>
),
},
{
key: 'app',
label: '应用',
render: (entry) => (
<>
<strong>{entry.title}</strong>
<small className="admin-muted-block">{entry.appId}</small>
</>
),
},
{
key: 'amount',
label: '金额',
render: (entry) =>
`${entry.currency} ${(entry.amountCents / 100).toFixed(2)}`,
},
{ key: 'provider', label: '渠道', render: (entry) => entry.provider },
{
key: 'status',
label: '状态',
render: (entry) => (
<AdminStatusPill tone={statusTone(entry.status)}>
{entry.status}
</AdminStatusPill>
),
},
{
key: 'created',
label: '创建时间',
render: (entry) => entry.createdAt,
},
{
key: 'paid',
label: '支付时间',
render: (entry) => entry.paidAt ?? '-',
},
]}
rows={entries}
rowKey={(entry) => entry.orderId}
/>
</AdminPage>
);
}
@@ -86,7 +86,7 @@ maud = "0.27.0"
libc = "0.2"
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.61", features = ["Wdk_Storage_FileSystem", "Win32_Foundation", "Win32_Storage_FileSystem", "Win32_System_Diagnostics_ToolHelp", "Win32_System_IO", "Win32_System_JobObjects", "Win32_System_Threading", "Win32_UI_WindowsAndMessaging"] }
windows-sys = { version = "0.61", features = ["Wdk_Storage_FileSystem", "Win32_Foundation", "Win32_Storage_FileSystem", "Win32_System_Diagnostics_ToolHelp", "Win32_System_IO", "Win32_System_JobObjects", "Win32_System_Threading", "Win32_UI_Shell", "Win32_UI_WindowsAndMessaging"] }
[profile.dev]
opt-level = 0
@@ -17,8 +17,14 @@
"browser.status": "整体 passed={}",
"browser.notStarted": "本次未启动 Chromium",
"browser.evidence": "[AGC 浏览器事实证据]\nattempt={attempt}; completionError={completion_status}; browser={browser_status}; codeFingerprintChanged={output_changed}\n{}\n诊断与平台素材运行时观察:{}\n交互探针:{}\n客户端已保存结构化证据;它是事实输入,不代表 Codex 已阅读截图或已经完成修复。当前 cwd 是项目根目录;按需读取实际文件,并自行决定是否修改、再次试玩或直接回复。AGC 只负责启动浏览器、采集证据和执行项目边界,Codex 负责解释结果。",
"browser.noFailureDetails": "无额外硬失败详情",
"browser.noVisibleControls": "未找到可执行的可见控件",
"browser.noFailureDetails": "无额外视觉检查失败详情",
"browser.noVisibleControls": "无建议性交互探针记录",
"browser.gameplayNotRun": "玩法检查:未执行(visual 仅提供视觉证据)。",
"browser.gameplayMissing": "固定场景玩法结果缺失,不能确认通过。",
"browser.gameplayNoDiagnostic": "无诊断记录",
"browser.gameplayNoUnmetAssertion": "无",
"browser.gameplayViewport": "{viewport}: gameplayPassed={passed};首条玩法诊断:{diagnostic};首个未通过断言:{assertion}",
"browser.gameplayEvidence": "[固定场景玩法证据]\n{}\n断言 passed=false 表示未通过,也可能因前序阻断而未执行;修复以具体诊断为准,不把所有未通过项视为独立故障。固定场景通过不代表完整关卡已验证;reportPath 仅为宿主证据引用,无需读取私有报告。",
"system.role": "你是陶泥儿,是 Genarrative 面向用户的游戏创作助手,负责当前任务的执行。先理解用户意图:普通对话直接回答,项目请求按需要检查、修改、运行和验证,并用简洁中文报告真实结果。",
"system.workspaceBoundary": "工作区边界:只在当前项目目录内工作;不要读取或输出凭据、Token、Cookie、auth.json、.env 或宿主私密路径。遇到阻断必须说明具体原因、文件和下一步,不要声称未验证的成功。",
"system.toolAuthorization": "AGC 工具授权:agc_tools 使用当前登录会话。工具返回 401/403 时,报告登录或权限状态异常并停止,交由用户处理登录和权限。",
@@ -10,6 +10,8 @@ Canvas/WebGL probes can show whether registered image sources entered a core ren
`mode=visual` captures desktop/mobile screenshots, loading, layout and diagnostics without clicking gameplay controls. It does not prove that the game loop works. `mode=gameplay` uses the fixed approved scenario (`generic-v1` by default, or the existing `tetris-v1` / `lane-defense-v1` contract), not an agent-written autopilot. It exercises the scenario independently in desktop and mobile viewports and requires both results. The receipt declares `gameplayViewports` separately from `visualViewports`. Older reports with only a desktop `playtest` result do not prove mobile gameplay. These bounded scenarios do not establish completion of an arbitrary long level.
Use each viewport's `gameplayResults` to diagnose failures: start with its first `diagnostics` entry, then inspect `assertions` and the recorded initial/final phase, sequence and level. An assertion with `passed=false` may not have run because an earlier step blocked; do not treat every unmet assertion as an independent defect. Missing viewport evidence is not a pass. In visual mode, `gameplayResults` is empty because gameplay was not run. `reportPath` is a private host evidence reference; use the returned diagnostics instead of trying to read that report.
For `generic-v1`, expose exactly one `script#playable-web-game-state[type="application/json"]` whose JSON is a projection of actual game state:
```json
@@ -1,6 +1,6 @@
{
"schemaVersion": "agc-skill-pack.v1",
"version": "2026-08-26.35",
"version": "2026-08-26.37",
"skills": [
{
"name": "agc-unity-editor",
@@ -140,7 +140,7 @@
"references/browser-evidence-contract.md",
"references/runner-physics.mjs"
],
"sha256": "e0e7bdca529e068ce2974a5e312a267108ab5acb20e8f9d7c0c22d049d90b796"
"sha256": "6800059c4e7bae70b0b42ec47175fc85e38d789e1759dca5df2067f715dc0657"
},
{
"name": "agc-client-projection",
@@ -29,9 +29,9 @@ mod direct_project_turn_history;
mod direct_runtime;
mod direct_tool_bridge;
mod direct_tools_mcp;
mod direct_turn_error;
mod direct_turn_failure;
mod direct_validation;
#[cfg(test)]
pub(crate) use direct_validation::browser_tool_result;
mod generation;
pub(crate) mod json_sidecar;
pub(crate) mod redaction;
@@ -45,16 +45,17 @@ pub mod tool;
pub(crate) use art_manifest::*;
use claude_code_cli::*;
pub(crate) use claude_code_cli::{
cancel_direct_claude_code_turn_at, direct_game_creator_claude_code_chat_at,
direct_game_creator_claude_code_home_chat, game_creator_claude_code_cli_route_error,
game_creator_claude_code_cli_version_identity,
cancel_direct_claude_code_turn_at, claude_code_failure_to_llm_error,
direct_game_creator_claude_code_chat_at, direct_game_creator_claude_code_home_chat,
game_creator_claude_code_cli_route_error, game_creator_claude_code_cli_version_identity,
};
pub(crate) use codex_app_server::direct_game_creator_codex_chat_at;
pub(crate) use codex_app_server::turn_error::*;
use codex_app_server::*;
#[cfg(not(test))]
pub(crate) use codex_app_server::{
cancel_direct_codex_turn_at, direct_game_creator_home_codex_chat, thread_id_for_project,
DirectTurnCancelView,
TurnCancelView,
};
use codex_cli::*;
pub(crate) use codex_cli::{
@@ -72,8 +73,6 @@ pub(crate) use direct_project_turn_history::*;
pub(crate) use direct_runtime::*;
pub(crate) use direct_tool_bridge::*;
pub(crate) use direct_tools_mcp::*;
pub(crate) use direct_turn_error::*;
pub(crate) use direct_turn_failure::*;
pub(crate) use direct_validation::DirectValidationConfig;
pub(crate) use generation::*;
pub(crate) use json_sidecar::*;
@@ -94,11 +94,14 @@ impl Drop for ActiveClaudeCodeTurnGuard {
}
}
struct ExternalMcpStopGuard;
struct ExternalMcpStopGuard {
root: PathBuf,
token: String,
}
impl Drop for ExternalMcpStopGuard {
fn drop(&mut self) {
stop_external_mcp_loopback();
stop_external_mcp_loopback_for_root(&self.root, &self.token);
}
}
@@ -375,7 +378,7 @@ async fn run_sidecar_turn(
pub(crate) fn cancel_direct_claude_code_turn_at(
root: &Path,
client_turn_id: Option<&str>,
) -> Result<Option<super::codex_app_server::DirectTurnCancelView>, String> {
) -> Result<Option<super::codex_app_server::TurnCancelView>, String> {
let key = claude_project_key(root);
let turns = active_claude_code_turns()
.lock()
@@ -394,7 +397,7 @@ pub(crate) fn cancel_direct_claude_code_turn_at(
active.alive.store(false, Ordering::Release);
kill_claude_code_process_tree(active.pid);
let client_turn_id = active.client_turn_id.clone();
Ok(Some(super::codex_app_server::DirectTurnCancelView {
Ok(Some(super::codex_app_server::TurnCancelView {
outcome: super::codex_app_server::DIRECT_TURN_CANCEL_OUTCOME_INTERRUPTED.to_string(),
message: "已向正在运行的 cc 回合发出终止".to_string(),
client_turn_id,
@@ -747,6 +750,46 @@ fn parse_usage(value: &serde_json::Value) -> Option<platform_llm::LlmTokenUsage>
})
}
/// 把 Claude Code/sidecar 的字符串失败投影到与 Codex app-server 相同的 LlmError 分类。
///
/// DirectProject 不能把上游 HTTP 错误统统包装成 Transport:那会把 429、401、5xx 等可识别
/// 的上游事实显示成“执行通道已断开”。这里只认结构化的状态位置(`Request rejected (N)` /
/// `HTTP N`),其它文本继续保留为 Transport,避免凭关键词猜测。
pub(crate) fn claude_code_failure_to_llm_error(detail: String) -> platform_llm::LlmError {
if let Some(status_code) = claude_code_failure_status_code(&detail) {
return platform_llm::LlmError::Upstream {
status_code,
message: detail,
};
}
if detail.contains("Claude Agent SDK sidecar 回合超时") {
return platform_llm::LlmError::Timeout { attempts: 1 };
}
if detail.contains("Claude Code 缺少最终回复") {
return platform_llm::LlmError::EmptyResponse;
}
if detail.contains("sidecar 输出不是有效 JSON") || detail.contains("stream-json 包含无效 JSON")
{
return platform_llm::LlmError::Deserialize(detail);
}
platform_llm::LlmError::Transport(detail)
}
fn claude_code_failure_status_code(detail: &str) -> Option<u16> {
["Request rejected (", "HTTP "].iter().find_map(|marker| {
let tail = detail.split_once(marker)?.1;
let digits = tail
.chars()
.take_while(|character| character.is_ascii_digit())
.collect::<String>();
if digits.len() != 3 {
return None;
}
let status_code = digits.parse::<u16>().ok()?;
(100..=599).contains(&status_code).then_some(status_code)
})
}
fn parse_claude_code_result(
stdout: &[u8],
request: &LlmRunRequest,
@@ -758,7 +801,7 @@ fn parse_claude_code_result(
let detail = claude_result_error_detail(&value)
.map(|detail| format!(":{detail}"))
.unwrap_or_default();
return Err(platform_llm::LlmError::Transport(format!(
return Err(claude_code_failure_to_llm_error(format!(
"Claude Code 返回失败终态{detail}"
)));
}
@@ -917,12 +960,16 @@ pub(crate) async fn direct_game_creator_claude_code_chat_at(
system_prompt: String,
user_prompt: String,
client_turn_id: Option<&str>,
observer: Option<&mut (dyn FnMut(DirectCodexTurnObservation) + Send)>,
observer: Option<&mut (dyn FnMut(TurnObservation) + Send)>,
) -> Result<String, String> {
direct_turn_trace("claude-executor-enter");
let (mcp_url, mcp_token) = start_external_mcp_loopback(root, llm.web_search_enabled).await?;
let (mcp_url, mcp_token) =
start_external_mcp_loopback_for_direct_turn(root, llm.web_search_enabled).await?;
direct_turn_trace("claude-mcp-loopback-ready");
let _mcp_guard = ExternalMcpStopGuard;
let _mcp_guard = ExternalMcpStopGuard {
root: root.to_path_buf(),
token: mcp_token.clone(),
};
let home = crate::project::resolve_local_project_path(root, ".agent/runtime/claude-code/home")?;
std::fs::create_dir_all(&home)
.map_err(|error| format!("创建 cc Direct 隔离目录失败:{error}"))?;
@@ -984,25 +1031,19 @@ pub(crate) async fn direct_game_creator_claude_code_chat_at(
// 项目对话历史是这条对话的单一事实源:回复不落盘,UI 就看不到本轮结果。codex 路径由
// app-server 的 collect-history 负责写 assistant 条目,cc 没有那一步——只补终态会让
// 用户看到"回合结束但没有回复"。落盘失败按回合失败收口,不吞。
let item_id = match client_turn_id {
Some(client_turn_id) => format!("direct-codex:{client_turn_id}:assistant"),
None => format!("direct-codex:{}:assistant", uuid::Uuid::new_v4()),
let item_id = match persist_direct_claude_assistant_reply_at(root, client_turn_id, text)
{
Ok(item_id) => item_id,
Err(error) => {
eprintln!("[agc-cc-direct] persist assistant failed: {error}");
direct_turn_trace("claude-parse-error");
return Err(format!("写入本项目对话历史失败:{error}"));
}
};
let item = serde_json::json!({
"type": "message",
"role": "assistant",
"id": item_id.clone(),
"content": [{ "type": "output_text", "text": text }],
});
if let Err(error) = append_direct_project_history_item_at(root, &item) {
eprintln!("[agc-cc-direct] persist assistant failed: {error}");
direct_turn_trace("claude-parse-error");
return Err(format!("写入本项目对话历史失败:{error}"));
}
// 聊天区是按 `item.completed` 事件流投影的(codex 路径在 rawResponseItem/completed
// 时下发同款事件),只落盘历史不会让本轮回复出现在界面上——重进项目才看得到。
// 条目身份与落盘的历史条目保持同一个,重进项目按 id 去重。
let at = crate::agent::direct_now_ms();
let at = crate::agent::now_ms();
crate::agent::append_thread_event(
&crate::agent::thread_id_for_project(root),
ThreadEvent::item_completed(
@@ -1026,9 +1067,41 @@ pub(crate) async fn direct_game_creator_claude_code_chat_at(
parsed
}
fn persist_direct_claude_assistant_reply_at(
root: &Path,
client_turn_id: Option<&str>,
text: &str,
) -> Result<String, String> {
let stable_item_id = match client_turn_id {
Some(client_turn_id) => format!("direct-codex:{client_turn_id}:assistant"),
None => format!("direct-codex:{}:assistant", uuid::Uuid::new_v4()),
};
let mut item = serde_json::json!({
"type": "message",
"role": "assistant",
"id": stable_item_id.clone(),
"content": [{ "type": "output_text", "text": text }],
});
match append_direct_project_history_item_at(root, &item) {
Ok(()) => Ok(stable_item_id),
Err(error) if error.starts_with("DirectProject 历史 item id 冲突:") => {
// 同一 client turn 的验收反馈会再次进入 cc 执行器。首个回复保留稳定身份,
// 后续不同回复必须另用 item id;否则历史层会把反馈收尾误判成失败。
let feedback_item_id = format!("{stable_item_id}:{}", uuid::Uuid::new_v4());
item["id"] = serde_json::Value::String(feedback_item_id.clone());
append_direct_project_history_item_at(root, &item)
.map(|()| feedback_item_id)
.map_err(|retry_error| {
format!("反馈回复历史追加失败:{retry_error}(首个条目冲突:{error})")
})
}
Err(error) => Err(error),
}
}
fn parse_direct_stream_result(
stdout: &[u8],
mut observer: Option<&mut (dyn FnMut(DirectCodexTurnObservation) + Send)>,
mut observer: Option<&mut (dyn FnMut(TurnObservation) + Send)>,
) -> Result<String, String> {
let text = std::str::from_utf8(stdout)
.map_err(|_| "Claude Agent SDK stream-json 不是 UTF-8".to_string())?;
@@ -1056,7 +1129,7 @@ fn parse_direct_stream_result(
.collect::<String>();
if !visible.is_empty() {
if let Some(observer) = observer.as_deref_mut() {
observer(DirectCodexTurnObservation::AgentMessageSegment(visible));
observer(TurnObservation::AgentMessageSegment(visible));
}
}
}
@@ -1130,6 +1203,29 @@ mod tests {
assert!(error.to_string().contains("Authentication failed"));
}
#[test]
fn claude_failure_reuses_codex_error_categories() {
assert!(matches!(
claude_code_failure_to_llm_error(
"Claude Code 返回失败终态:API Error: Request rejected (429)".into()
),
platform_llm::LlmError::Upstream {
status_code: 429,
..
}
));
assert!(matches!(
claude_code_failure_to_llm_error(
"Claude Agent SDK sidecar 回合超时:连续 180000 ms 没有任何事件".into()
),
platform_llm::LlmError::Timeout { attempts: 1 }
));
assert!(matches!(
claude_code_failure_to_llm_error("Claude Code 缺少最终回复".into()),
platform_llm::LlmError::EmptyResponse
));
}
#[test]
fn parses_direct_stream_result_and_final_text() {
let mut observed = Vec::new();
@@ -1145,7 +1241,7 @@ mod tests {
assert_eq!(text, "最终回复");
assert!(matches!(
observed.as_slice(),
[DirectCodexTurnObservation::AgentMessageSegment(_)]
[TurnObservation::AgentMessageSegment(_)]
));
}
@@ -1160,6 +1256,28 @@ mod tests {
assert!(error.contains("Authentication failed"));
}
#[test]
fn direct_claude_feedback_reply_does_not_fail_on_a_reused_client_turn_id() {
let root = tempfile::tempdir().expect("temp dir");
crate::init_local_game_project_at(root.path(), "cc-feedback", "cc feedback")
.expect("init project");
let turn_id = Some("client-turn-feedback-0001");
let first = persist_direct_claude_assistant_reply_at(root.path(), turn_id, "首轮回复")
.expect("persist first reply");
let identical = persist_direct_claude_assistant_reply_at(root.path(), turn_id, "首轮回复")
.expect("an identical retry remains idempotent");
let second = persist_direct_claude_assistant_reply_at(root.path(), turn_id, "反馈后回复")
.expect("a host feedback retry must persist a second reply");
assert_eq!(first, identical);
assert_ne!(first, second);
assert!(second.starts_with("direct-codex:client-turn-feedback-0001:assistant:"));
let items =
crate::agent::read_direct_project_history_items_at(root.path()).expect("read history");
assert_eq!(items.len(), 2);
}
fn test_platform_session() -> crate::platform_session::PlatformSessionSnapshot {
crate::platform_session::PlatformSessionSnapshot {
user_id: "user-1".to_string(),
@@ -1,7 +1,7 @@
//! Native / third-party approval adapter. The host execution session owns policy
//! and persistence; this module only binds the app-server protocol to its leases.
use super::super::{direct_delivery, direct_execution, direct_validation, DirectTurnError};
use super::super::{direct_delivery, direct_execution, direct_validation, TurnError};
use super::{shutdown_game_creator_codex_app_server_inner, CodexAppServerInner};
use direct_execution::{EffectKind, ExecutionLease, ExecutionPhase, ExecutionSession};
use serde_json::{json, Value};
@@ -152,12 +152,10 @@ pub(super) const HOST_OUTCOME_REPAIR_REQUIRED_DETAIL: &str =
impl HostOutcomeText {
/// 投影成这一轮的收尾结果:正常报告是文本,返修要求是控制流(走 `Err` 侧自己的变体)。
pub(super) fn into_run_result(self) -> Result<String, super::DirectTurnRunFailure> {
pub(super) fn into_run_result(self) -> Result<String, super::RunFailure> {
match self {
Self::Report(text) => Ok(text),
Self::RepairRequired { detail } => {
Err(super::DirectTurnRunFailure::RepairRequired { detail })
}
Self::RepairRequired { detail } => Err(super::RunFailure::RepairRequired { detail }),
}
}
}
@@ -246,7 +244,7 @@ pub(super) struct ExecutionAdapter {
outcome: watch::Sender<Option<HostOutcome>>,
/// 宿主自己判定的"本轮以失败收口":`(分类, 原因)`。有值就代表本轮终态必须是失败,
/// 原因与交付报告同一份文本。
turn_failure: Mutex<Option<DirectTurnError>>,
turn_failure: Mutex<Option<TurnError>>,
/// 用户/宿主是否主动要求终止这一轮(界面的「终止」按钮)。用户主动终止不是失败。
host_stop_requested: AtomicBool,
/// 已留痕的拒绝原因(`method\u{1}reason`)。同一回合内同因只记一次,避免模型重试刷屏。
@@ -921,8 +919,8 @@ impl ExecutionAdapter {
///
/// 只记第一份:第一份最接近现场(连接终止时带 exitStatus / stderr 摘要),后面更粗的收束理由
/// 不得覆盖它。
pub(super) async fn fail_turn(&self, failure: DirectTurnError) {
let reason = failure.to_string();
pub(super) async fn fail_turn(&self, failure: TurnError) {
let reason = failure.diagnostic_detail();
if self.is_closed() {
// 宿主自己收尾:连接是我们先关的,紧随其后的 `TransportClosed` 只是收尾的副产物。
// 只把原因留给报告,不改阶段——否则正常的宿主收尾会被改写成 `interrupted`
@@ -944,7 +942,7 @@ impl ExecutionAdapter {
}
/// 本轮以什么理由失败;有值就是宿主记下的 typed 事实。终态判定只读这一次。
pub(super) fn turn_failure(&self) -> Option<DirectTurnError> {
pub(super) fn turn_failure(&self) -> Option<TurnError> {
self.turn_failure.lock().ok().and_then(|slot| slot.clone())
}
@@ -1041,7 +1039,7 @@ impl ExecutionAdapter {
pub(super) fn lifecycle_status(&self, fallback: &str) -> String {
// 只按收尾阶段归类。失败事实(`fail_turn` 记下的)不在这里翻案:终态由
// `direct_turn_terminal` 拿事实判定——否则"模型已经判失败"的一轮会被这里的
// `turn_terminal` 拿事实判定——否则"模型已经判失败"的一轮会被这里的
// `Interrupted` 抹成一次没有原因的"已结束"。
match self.session.snapshot().map(|state| state.phase) {
Ok(ExecutionPhase::Completed) => "completed",
@@ -1335,7 +1333,7 @@ pub(super) async fn wait_outcome(
#[cfg(test)]
mod tests {
use super::super::DirectTurnDeadline;
use super::super::{Deadline, TimedOut, TransportClosed};
use super::*;
@@ -1391,34 +1389,28 @@ mod tests {
assert!(!adapter.host_stop_requested());
adapter
.fail_turn(DirectTurnError::TransportClosed {
.fail_turn(TurnError::TransportClosed(TransportClosed {
diagnostic: "Codex app-server 已退出;exitStatus=signal: 9 (SIGKILL)".into(),
})
}))
.await;
// 终态判定读这份事实,界面才有理由把它当失败讲,而不是"本轮已结束"。
let failure = adapter.turn_failure().expect("host fact must be recorded");
assert_eq!(
failure.wire_kind(),
Some(super::super::DirectTurnFailureKind::TransportFailed)
);
assert!(failure.to_string().contains("SIGKILL"));
assert!(matches!(failure, TurnError::TransportClosed(_)));
assert!(failure.diagnostic_detail().contains("SIGKILL"));
// 报告与事件载荷同一份原因:用户看到的现象和交付状态对得上。
assert!(adapter.report().contains("SIGKILL"));
// 只认第一份原因:后续更粗的收束理由不得覆盖真实诊断。
adapter
.fail_turn(DirectTurnError::TimedOut {
deadline: DirectTurnDeadline::ResponseIdle,
})
.fail_turn(TurnError::TimedOut(TimedOut {
deadline: Deadline::ResponseIdle,
}))
.await;
let failure = adapter.turn_failure().expect("first reason is kept");
assert_eq!(
failure.wire_kind(),
Some(super::super::DirectTurnFailureKind::TransportFailed)
);
assert!(failure.to_string().contains("SIGKILL"));
assert!(!failure.to_string().contains("超时"));
assert!(matches!(failure, TurnError::TransportClosed(_)));
assert!(failure.diagnostic_detail().contains("SIGKILL"));
assert!(!failure.diagnostic_detail().contains("超时"));
}
/// 宿主自己关的连接不算失败:正常终态、用户主动停止、预算与交付收尾都会关掉连接,回合事件通道
@@ -1430,9 +1422,9 @@ mod tests {
adapter.closed.store(true, Ordering::Release);
adapter
.fail_turn(DirectTurnError::TransportClosed {
.fail_turn(TurnError::TransportClosed(TransportClosed {
diagnostic: "模型本次执行结束,回收原生后台子树".into(),
})
}))
.await;
assert!(adapter.turn_failure().is_none());
@@ -1457,9 +1449,9 @@ mod tests {
adapter.request_host_stop();
adapter
.fail_turn(DirectTurnError::TransportClosed {
.fail_turn(TurnError::TransportClosed(TransportClosed {
diagnostic: "Codex app-server 已退出;exitStatus=signal: 9 (SIGKILL)".into(),
})
}))
.await;
assert!(adapter.turn_failure().is_none());
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -4,7 +4,7 @@ mod model;
mod validation;
mod wire;
pub(crate) use model::DirectCodexUserItem;
pub(crate) use model::UserItem;
pub(crate) use wire::{
direct_codex_user_item_to_codex_turn_input, direct_codex_user_item_to_prompt,
direct_codex_user_item_to_response_item, freeze_direct_codex_user_item,
@@ -5,31 +5,31 @@ use ts_rs::TS;
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize, TS)]
#[serde(tag = "type", deny_unknown_fields)]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))]
pub(crate) enum DirectCodexUserItem {
pub(crate) enum UserItem {
#[serde(rename = "message")]
Message(DirectCodexUserMessageItem),
Message(UserMessageItem),
}
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize, TS)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))]
pub(crate) struct DirectCodexUserMessageItem {
pub(crate) role: DirectCodexUserRole,
pub(crate) content: Vec<DirectCodexUserContentPart>,
pub(crate) struct UserMessageItem {
pub(crate) role: UserRole,
pub(crate) content: Vec<UserContentPart>,
pub(crate) id: String,
}
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize, TS)]
#[serde(rename_all = "lowercase")]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))]
pub(crate) enum DirectCodexUserRole {
pub(crate) enum UserRole {
User,
}
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize, TS)]
#[serde(tag = "type", rename_all_fields = "camelCase", deny_unknown_fields)]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))]
pub(crate) enum DirectCodexUserContentPart {
pub(crate) enum UserContentPart {
#[serde(rename = "input_text")]
InputText { text: String },
#[serde(rename = "agc_resource_reference")]
@@ -44,16 +44,16 @@ pub(crate) enum DirectCodexUserContentPart {
#[serde(rename = "agc_skill_reference")]
AgcSkillReference { name: String },
#[serde(rename = "agc_runtime_region_reference")]
AgcRuntimeRegionReference(DirectCodexUserRuntimeRegionPart),
AgcRuntimeRegionReference(UserRuntimeRegionPart),
/// Uploaded project attachment kept inline in canonical content.
#[serde(rename = "agc_attachment_reference")]
AgcAttachmentReference(DirectCodexUserAttachmentReferencePart),
AgcAttachmentReference(UserAttachmentReferencePart),
}
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize, TS)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))]
pub(crate) struct DirectCodexUserAttachmentReferencePart {
pub(crate) struct UserAttachmentReferencePart {
pub(crate) name: String,
pub(crate) media_type: String,
#[ts(type = "number")]
@@ -65,7 +65,7 @@ pub(crate) struct DirectCodexUserAttachmentReferencePart {
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize, TS)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))]
pub(crate) struct DirectCodexUserRuntimeRegionPart {
pub(crate) struct UserRuntimeRegionPart {
pub(crate) label: String,
#[serde(default)]
pub(crate) run_id: Option<String>,
@@ -92,9 +92,9 @@ mod tests {
#[test]
fn resource_reference_serializes_with_only_camel_case_resource_id() {
let item = DirectCodexUserItem::Message(DirectCodexUserMessageItem {
role: DirectCodexUserRole::User,
content: vec![DirectCodexUserContentPart::AgcResourceReference {
let item = UserItem::Message(UserMessageItem {
role: UserRole::User,
content: vec![UserContentPart::AgcResourceReference {
resource_id: "asset-hero".to_string(),
resolved_text: None,
}],
@@ -116,7 +116,7 @@ mod tests {
#[test]
fn resource_reference_rejects_extra_identity_fields() {
let error = serde_json::from_value::<DirectCodexUserItem>(json!({
let error = serde_json::from_value::<UserItem>(json!({
"type": "message",
"role": "user",
"content": [{
@@ -133,7 +133,7 @@ mod tests {
/// 旧历史里的资源引用没有 `resolvedText`:缺省必须合法,照样解析成 `None` 的引用。
#[test]
fn resource_reference_without_resolved_text_still_parses() {
let item: DirectCodexUserItem = serde_json::from_value(json!({
let item: UserItem = serde_json::from_value(json!({
"type": "message",
"role": "user",
"content": [{
@@ -143,9 +143,9 @@ mod tests {
"id": "turn-1"
}))
.expect("legacy resource reference must keep parsing");
let DirectCodexUserItem::Message(message) = item;
let UserItem::Message(message) = item;
match &message.content[0] {
DirectCodexUserContentPart::AgcResourceReference {
UserContentPart::AgcResourceReference {
resource_id,
resolved_text,
} => {
@@ -158,7 +158,7 @@ mod tests {
#[test]
fn skill_reference_serializes_with_only_the_stable_name() {
let item: DirectCodexUserItem = serde_json::from_value(json!({
let item: UserItem = serde_json::from_value(json!({
"type": "message",
"role": "user",
"content": [{"type": "agc_skill_reference", "name": "agc-web-game-development"}],
@@ -172,7 +172,7 @@ mod tests {
// canonical part 不接受正文、路径或凭据类附加字段:它们只可能来自宿主私密状态。
for forbidden in ["path", "body", "content", "token", "apiKey"] {
let error = serde_json::from_value::<DirectCodexUserItem>(json!({
let error = serde_json::from_value::<UserItem>(json!({
"type": "message",
"role": "user",
"content": [{
@@ -192,7 +192,7 @@ mod tests {
#[test]
fn unknown_content_part_fails_closed() {
serde_json::from_value::<DirectCodexUserItem>(json!({
serde_json::from_value::<UserItem>(json!({
"type": "message",
"role": "user",
"content": [{"type": "future_part", "value": "x"}],
@@ -1,7 +1,4 @@
use super::model::{
DirectCodexUserContentPart, DirectCodexUserItem, DirectCodexUserRole,
DirectCodexUserRuntimeRegionPart,
};
use super::model::{UserContentPart, UserItem, UserRole, UserRuntimeRegionPart};
use crate::agent::{
read_manifest_for_project, sanitize_attachment_local_path, GameCreationAppManifest,
MAX_DIRECT_CODEX_ATTACHMENTS, MAX_DIRECT_CODEX_ATTACHMENT_MEDIA_TYPE_CHARS,
@@ -16,10 +13,10 @@ pub(crate) const MAX_DIRECT_CODEX_SKILL_REFERENCES: usize = 32;
pub(crate) fn validate_direct_codex_user_item(
root: &Path,
item: &DirectCodexUserItem,
item: &UserItem,
) -> Result<GameCreationAppManifest, String> {
let DirectCodexUserItem::Message(message) = item;
if !matches!(message.role, DirectCodexUserRole::User) {
let UserItem::Message(message) = item;
if !matches!(message.role, UserRole::User) {
return Err("DirectProject 只接受 user message item".to_string());
}
if message.id.trim().is_empty() {
@@ -36,12 +33,12 @@ pub(crate) fn validate_direct_codex_user_item(
let mut skill_count = 0usize;
for part in &message.content {
match part {
DirectCodexUserContentPart::InputText { .. } => {}
DirectCodexUserContentPart::AgcResourceReference { resource_id, .. } => {
UserContentPart::InputText { .. } => {}
UserContentPart::AgcResourceReference { resource_id, .. } => {
reference_count = reference_count.saturating_add(1);
validate_resource_id_and_manifest(&manifest, resource_id)?;
}
DirectCodexUserContentPart::AgcSkillReference { name } => {
UserContentPart::AgcSkillReference { name } => {
skill_count = skill_count.saturating_add(1);
if skill_count > MAX_DIRECT_CODEX_SKILL_REFERENCES {
return Err(format!(
@@ -61,11 +58,11 @@ pub(crate) fn validate_direct_codex_user_item(
return Err("引用的 Skill 名称无效,请移除后重新选择".to_string());
}
}
DirectCodexUserContentPart::AgcRuntimeRegionReference(reference) => {
UserContentPart::AgcRuntimeRegionReference(reference) => {
reference_count = reference_count.saturating_add(1);
validate_runtime_region_reference(&manifest, reference)?;
}
DirectCodexUserContentPart::AgcAttachmentReference(reference) => {
UserContentPart::AgcAttachmentReference(reference) => {
attachment_count = attachment_count.saturating_add(1);
if attachment_count > MAX_DIRECT_CODEX_ATTACHMENTS {
return Err(format!(
@@ -112,9 +109,9 @@ pub(crate) fn validate_direct_codex_user_item(
}
/// 整条 content 是否还有有效输入:任何一段非空白文本、或任何一个非文本 part 都算。
pub(crate) fn content_has_meaningful_input(content: &[DirectCodexUserContentPart]) -> bool {
pub(crate) fn content_has_meaningful_input(content: &[UserContentPart]) -> bool {
content.iter().any(|part| match part {
DirectCodexUserContentPart::InputText { text } => !text.trim().is_empty(),
UserContentPart::InputText { text } => !text.trim().is_empty(),
_ => true,
})
}
@@ -142,7 +139,7 @@ pub(crate) fn validate_resource_id_and_manifest(
fn validate_runtime_region_reference(
manifest: &GameCreationAppManifest,
reference: &DirectCodexUserRuntimeRegionPart,
reference: &UserRuntimeRegionPart,
) -> Result<(), String> {
if reference.label.trim().is_empty() {
return Err("运行画面区域缺少名称".to_string());
@@ -164,11 +161,11 @@ mod tests {
content_has_meaningful_input, validate_direct_codex_user_item,
MAX_DIRECT_CODEX_SKILL_REFERENCES,
};
use crate::agent::direct_codex_user_item::model::DirectCodexUserContentPart;
use crate::agent::direct_codex_user_item::model::UserContentPart;
use serde_json::json;
fn input_text(text: &str) -> DirectCodexUserContentPart {
DirectCodexUserContentPart::InputText {
fn input_text(text: &str) -> UserContentPart {
UserContentPart::InputText {
text: text.to_string(),
}
}
@@ -200,7 +197,7 @@ mod tests {
#[test]
fn non_text_parts_always_count_as_input() {
assert!(content_has_meaningful_input(&[
DirectCodexUserContentPart::AgcResourceReference {
UserContentPart::AgcResourceReference {
resource_id: "asset-hero".to_string(),
resolved_text: None,
},
@@ -1,6 +1,5 @@
use super::model::{
DirectCodexUserAttachmentReferencePart, DirectCodexUserContentPart, DirectCodexUserItem,
DirectCodexUserMessageItem, DirectCodexUserRuntimeRegionPart,
UserAttachmentReferencePart, UserContentPart, UserItem, UserMessageItem, UserRuntimeRegionPart,
};
use super::validation::validate_direct_codex_user_item;
use crate::agent::{
@@ -15,21 +14,21 @@ use serde_json::Value;
use std::path::Path;
/// 入队检查产出的**冻结条目**:校验 → 把每个引用 part 的解析文本写进它自己的
/// [`DirectCodexUserContentPart::AgcResourceReference::resolved_text`] → 判空。
/// [`UserContentPart::AgcResourceReference::resolved_text`] → 判空。
///
/// 这是这条消息**唯一**会算片段、会写盘(UI 设计文档代码导出)的地方,也是唯一的失败出口:
/// 之后的 prompt 折叠([`direct_codex_user_item_to_prompt`])与放行都不再重算、不再读 manifest,
/// 因此也没有失败可言。冻结结果随条目一路走到历史、事件与 turn input——它们读的都是同一份文本。
pub(crate) fn freeze_direct_codex_user_item(
root: &Path,
item: &DirectCodexUserItem,
) -> Result<DirectCodexUserItem, String> {
item: &UserItem,
) -> Result<UserItem, String> {
let manifest = validate_direct_codex_user_item(root, item)?;
let DirectCodexUserItem::Message(message) = item;
let UserItem::Message(message) = item;
let mut content = Vec::with_capacity(message.content.len());
for part in &message.content {
match part {
DirectCodexUserContentPart::AgcResourceReference {
UserContentPart::AgcResourceReference {
resource_id,
resolved_text,
} => {
@@ -42,7 +41,7 @@ pub(crate) fn freeze_direct_codex_user_item(
resource_id,
)?),
};
content.push(DirectCodexUserContentPart::AgcResourceReference {
content.push(UserContentPart::AgcResourceReference {
resource_id: resource_id.clone(),
resolved_text,
});
@@ -50,7 +49,7 @@ pub(crate) fn freeze_direct_codex_user_item(
other => content.push(other.clone()),
}
}
let frozen = DirectCodexUserItem::Message(DirectCodexUserMessageItem {
let frozen = UserItem::Message(UserMessageItem {
role: message.role.clone(),
content,
id: message.id.clone(),
@@ -75,7 +74,7 @@ pub(crate) fn direct_codex_user_item_to_response_item(
}
return Err("DirectProject 历史 item 缺少 type,无法投影为 Codex item".to_string());
}
let canonical: DirectCodexUserItem = serde_json::from_value(item.clone())
let canonical: UserItem = serde_json::from_value(item.clone())
.map_err(|error| format!("DirectProject user item 无法转换为 Codex item:{error}"))?;
let content = direct_codex_user_item_to_response_content(root, &canonical)?;
let mut projected = serde_json::json!({
@@ -91,7 +90,7 @@ pub(crate) fn direct_codex_user_item_to_response_item(
fn direct_codex_user_item_to_response_content(
root: &Path,
item: &DirectCodexUserItem,
item: &UserItem,
) -> Result<Vec<Value>, String> {
let Value::Array(input) = direct_codex_user_item_to_wire_input(root, item)? else {
return Err("DirectProject user item wire content 不是数组".to_string());
@@ -125,7 +124,7 @@ fn resource_reference_summary(
))
}
fn runtime_region_summary(reference: &DirectCodexUserRuntimeRegionPart) -> String {
fn runtime_region_summary(reference: &UserRuntimeRegionPart) -> String {
let resources = reference
.resource_ids
.iter()
@@ -153,7 +152,7 @@ fn runtime_region_summary(reference: &DirectCodexUserRuntimeRegionPart) -> Strin
///
/// turn 输入与 history/prompt 投影共用这一份清洗:文件名取 basename 并去控制字符、
/// media type 与项目路径同样过白名单,避免两条路径对同一个引用给出不同摘要。
fn attachment_reference_summary(reference: &DirectCodexUserAttachmentReferencePart) -> String {
fn attachment_reference_summary(reference: &UserAttachmentReferencePart) -> String {
let name = sanitize_attachment_name(&reference.name);
let media_type = sanitize_attachment_media_type(&reference.media_type);
let mut summary = format!(
@@ -172,16 +171,16 @@ fn attachment_reference_summary(reference: &DirectCodexUserAttachmentReferencePa
/// AGC 私有 part 只在这里投影为安全摘要,canonical item 本身不被修改。
pub(crate) fn direct_codex_user_item_to_wire_input(
root: &Path,
item: &DirectCodexUserItem,
item: &UserItem,
) -> Result<Value, String> {
// validate 已经读过清单并返回它,不要再读一次(seed task 变更也会被重复触发)。
let manifest = validate_direct_codex_user_item(root, item)?;
let DirectCodexUserItem::Message(message) = item;
let UserItem::Message(message) = item;
let mut input = Vec::with_capacity(message.content.len());
for part in &message.content {
let text = match part {
DirectCodexUserContentPart::InputText { text } => text.clone(),
DirectCodexUserContentPart::AgcResourceReference {
UserContentPart::InputText { text } => text.clone(),
UserContentPart::AgcResourceReference {
resource_id,
resolved_text,
} => match resolved_text {
@@ -190,13 +189,13 @@ pub(crate) fn direct_codex_user_item_to_wire_input(
// 旧历史没有这份文本,退回按当前 manifest 现算(只有摘要,不产生写副作用)。
None => resource_reference_summary(&manifest, resource_id)?,
},
DirectCodexUserContentPart::AgcSkillReference { name } => {
UserContentPart::AgcSkillReference { name } => {
format!("${}", name.trim())
}
DirectCodexUserContentPart::AgcRuntimeRegionReference(reference) => {
UserContentPart::AgcRuntimeRegionReference(reference) => {
runtime_region_summary(reference)
}
DirectCodexUserContentPart::AgcAttachmentReference(reference) => {
UserContentPart::AgcAttachmentReference(reference) => {
attachment_reference_summary(reference)
}
};
@@ -207,18 +206,18 @@ pub(crate) fn direct_codex_user_item_to_wire_input(
pub(crate) fn direct_codex_user_item_to_codex_turn_input(
root: &Path,
item: &DirectCodexUserItem,
item: &UserItem,
skill_roots: &[std::path::PathBuf],
) -> Result<Value, String> {
let manifest = validate_direct_codex_user_item(root, item)?;
let DirectCodexUserItem::Message(message) = item;
let UserItem::Message(message) = item;
let mut input = Vec::with_capacity(message.content.len());
for part in &message.content {
match part {
DirectCodexUserContentPart::InputText { text } => {
UserContentPart::InputText { text } => {
input.push(serde_json::json!({ "type": "text", "text": text }));
}
DirectCodexUserContentPart::AgcResourceReference {
UserContentPart::AgcResourceReference {
resource_id,
resolved_text,
} => {
@@ -230,7 +229,7 @@ pub(crate) fn direct_codex_user_item_to_codex_turn_input(
},
}));
}
DirectCodexUserContentPart::AgcSkillReference { name } => {
UserContentPart::AgcSkillReference { name } => {
let name = name.trim();
let path = skill_roots
.iter()
@@ -243,13 +242,13 @@ pub(crate) fn direct_codex_user_item_to_codex_turn_input(
"path": path,
}));
}
DirectCodexUserContentPart::AgcRuntimeRegionReference(reference) => {
UserContentPart::AgcRuntimeRegionReference(reference) => {
input.push(serde_json::json!({
"type": "text",
"text": runtime_region_summary(reference),
}));
}
DirectCodexUserContentPart::AgcAttachmentReference(reference) => {
UserContentPart::AgcAttachmentReference(reference) => {
input.push(serde_json::json!({
"type": "text",
"text": attachment_reference_summary(reference),
@@ -266,24 +265,24 @@ pub(crate) fn direct_codex_user_item_to_codex_turn_input(
/// [`freeze_direct_codex_user_item`] 冻结,随条目持久化)。条目里没有引用之外的东西要算,
/// 所以这里也不再需要 root。调用方要保证条目是冻结过的;缺省 `resolved_text` 的旧条目
/// 走不了这一条(历史回读用 [`direct_codex_user_item_to_response_item`])。
pub(crate) fn direct_codex_user_item_to_prompt(item: &DirectCodexUserItem) -> String {
let DirectCodexUserItem::Message(message) = item;
pub(crate) fn direct_codex_user_item_to_prompt(item: &UserItem) -> String {
let UserItem::Message(message) = item;
let mut prompt = String::new();
for part in &message.content {
match part {
DirectCodexUserContentPart::InputText { text } => prompt.push_str(text),
DirectCodexUserContentPart::AgcResourceReference { resolved_text, .. } => {
UserContentPart::InputText { text } => prompt.push_str(text),
UserContentPart::AgcResourceReference { resolved_text, .. } => {
if let Some(text) = resolved_text {
prompt.push_str(text);
}
}
DirectCodexUserContentPart::AgcSkillReference { name } => {
UserContentPart::AgcSkillReference { name } => {
prompt.push_str(&format!("${}", name.trim()));
}
DirectCodexUserContentPart::AgcRuntimeRegionReference(reference) => {
UserContentPart::AgcRuntimeRegionReference(reference) => {
prompt.push_str(&runtime_region_summary(reference));
}
DirectCodexUserContentPart::AgcAttachmentReference(reference) => {
UserContentPart::AgcAttachmentReference(reference) => {
prompt.push_str(&attachment_reference_summary(reference));
}
}
@@ -351,7 +350,7 @@ mod tests {
direct_codex_user_item_to_response_item, direct_codex_user_item_to_wire_input,
freeze_direct_codex_user_item, validate_direct_codex_user_item,
};
use crate::agent::direct_codex_user_item::model::DirectCodexUserItem;
use crate::agent::direct_codex_user_item::model::UserItem;
use crate::ui_editor::persistence::UI_DESIGN_DOC_MEDIA_TYPE;
use serde_json::json;
use shared_contracts::game_creation_app::{
@@ -437,7 +436,7 @@ mod tests {
#[test]
fn ui_design_doc_reference_appends_generated_code_context() {
let (project, asset_id) = ui_design_doc_fixture(true);
let item: super::DirectCodexUserItem =
let item: super::UserItem =
serde_json::from_value(user_item_with_resource_reference(&asset_id))
.expect("canonical user item");
let frozen = freeze_direct_codex_user_item(project.path(), &item).expect("freeze");
@@ -453,9 +452,9 @@ mod tests {
"{prompt}"
);
// 解析文本随条目持久化:历史回放读的就是这一份,不再重算、不再写盘。
let super::DirectCodexUserItem::Message(message) = &frozen;
let super::UserItem::Message(message) = &frozen;
match &message.content[0] {
super::DirectCodexUserContentPart::AgcResourceReference { resolved_text, .. } => {
super::UserContentPart::AgcResourceReference { resolved_text, .. } => {
assert_eq!(resolved_text.as_deref(), Some(prompt.as_str()));
}
other => panic!("expected a frozen resource reference, got {other:?}"),
@@ -488,7 +487,7 @@ mod tests {
#[test]
fn ui_design_generation_failure_keeps_reference_and_reports_error() {
let (project, asset_id) = ui_design_doc_fixture(false);
let item: super::DirectCodexUserItem =
let item: super::UserItem =
serde_json::from_value(user_item_with_resource_reference(&asset_id))
.expect("canonical user item");
let frozen = freeze_direct_codex_user_item(project.path(), &item).expect("freeze");
@@ -506,7 +505,7 @@ mod tests {
GameCreationAppAssetKind::Character,
"image/png",
);
let item: super::DirectCodexUserItem = serde_json::from_value(json!({
let item: super::UserItem = serde_json::from_value(json!({
"type": "message",
"role": "user",
"id": "turn-multi-1:user",
@@ -547,7 +546,7 @@ mod tests {
GameCreationAppAssetKind::Character,
"image/png",
);
let item: super::DirectCodexUserItem =
let item: super::UserItem =
serde_json::from_value(user_item_with_resource_reference(&asset_id))
.expect("canonical user item");
let frozen = freeze_direct_codex_user_item(project.path(), &item).expect("freeze");
@@ -774,8 +773,7 @@ mod tests {
{"type": "agc_skill_reference", "name": "missing-skill"}
]
});
let user_item: DirectCodexUserItem =
serde_json::from_value(item).expect("parse canonical item");
let user_item: UserItem = serde_json::from_value(item).expect("parse canonical item");
// 已启用目录里没有这个 Skill:转换必须在启动回合前失败关闭,
// 不能把不可用的引用降级成正文放行。
let error = direct_codex_user_item_to_codex_turn_input(
@@ -804,8 +802,7 @@ mod tests {
{"type": "input_text", "text": "然后创建菜单"}
]
});
let user_item: DirectCodexUserItem =
serde_json::from_value(item).expect("parse canonical item");
let user_item: UserItem = serde_json::from_value(item).expect("parse canonical item");
let input =
direct_codex_user_item_to_codex_turn_input(root.path(), &user_item, &[skill_root])
.expect("available skill should convert");
@@ -884,7 +881,7 @@ mod tests {
let root = tempfile::tempdir().expect("temp project");
crate::init_local_game_project_at(root.path(), "wire-test", "wire 投影测试")
.expect("init project");
let item: DirectCodexUserItem = serde_json::from_value(json!({
let item: UserItem = serde_json::from_value(json!({
"type": "message",
"role": "user",
"id": "turn-1:user",
@@ -908,7 +905,7 @@ mod tests {
let root = tempfile::tempdir().expect("temp project");
crate::init_local_game_project_at(root.path(), "wire-test", "wire 投影测试")
.expect("init project");
let item: DirectCodexUserItem = serde_json::from_value(json!({
let item: UserItem = serde_json::from_value(json!({
"type": "message",
"role": "user",
"id": "turn-1:user",
@@ -698,12 +698,12 @@ pub(super) async fn finish_sealing(
/// 回合末的宿主复核:返回要交付的答复,或者一个"还没完,按这份证据继续修"的要求。
///
/// 返修要求是**控制流**([`DirectTurnError::ReviewRequired`]),不是失败:调用方据此把要求写回
/// 返修要求是**控制流**([`TurnError::ReviewRequired`]),不是失败:调用方据此把要求写回
/// prompt 再跑一轮,界面不该看到失败文案。其余错误都是真的回合失败,按 typed 错误交给上层。
pub(super) async fn review_reply(
root: &Path,
session: &Arc<ExecutionSession>,
) -> Result<Option<String>, DirectTurnError> {
) -> Result<Option<String>, TurnError> {
if let Some(report) = terminal_report(session) {
return Ok(Some(report));
}
@@ -754,7 +754,7 @@ pub(super) async fn review_reply(
.map_err(|_| "delivery-review-worker-exited")??;
return Ok(Some(report));
}
Err(DirectTurnError::ReviewRequired {
Err(TurnError::ReviewRequired {
detail: format!("delivery-review-required: {detail}"),
})
}
@@ -919,7 +919,7 @@ mod tests {
for _ in 0..2 {
assert!(matches!(
review_reply(new_game.path(), &required).await.unwrap_err(),
DirectTurnError::ReviewRequired { .. }
TurnError::ReviewRequired { .. }
));
}
assert!(review_reply(new_game.path(), &required)
File diff suppressed because it is too large Load Diff
@@ -7,9 +7,9 @@ use super::*;
pub(crate) fn normalize_direct_client_turn_id(
client_turn_id: Option<&str>,
) -> Result<String, DirectTurnError> {
) -> Result<String, EnqueueError> {
let Some(client_turn_id) = client_turn_id else {
return Err(DirectTurnError::ClientTurnIdMissing);
return Err(EnqueueError::ClientTurnIdMissing);
};
let client_turn_id = client_turn_id.trim();
let valid_length = (MIN_DIRECT_CLIENT_TURN_ID_CHARS..=MAX_DIRECT_CLIENT_TURN_ID_CHARS)
@@ -20,10 +20,10 @@ pub(crate) fn normalize_direct_client_turn_id(
.is_some_and(|byte| byte.is_ascii_alphanumeric());
let valid_rest = bytes.all(|byte| byte.is_ascii_alphanumeric() || byte == b'-');
if !valid_length || !valid_first || !valid_rest {
return Err(DirectTurnError::ClientTurnIdMalformed {
return Err(EnqueueError::ClientTurnIdMalformed(ClientTurnIdMalformed {
min_chars: MIN_DIRECT_CLIENT_TURN_ID_CHARS,
max_chars: MAX_DIRECT_CLIENT_TURN_ID_CHARS,
});
}));
}
Ok(client_turn_id.to_string())
}
@@ -37,7 +37,7 @@ pub(crate) fn normalize_direct_client_turn_id(
/// 于是"这一轮跑成什么"仍然只有订阅事件一个来源:命令返回 `Ok` 只说明**入队成立**。真正的回合边界
/// (`turn.started` / `turn.completed`)由 Thread Manager 在**放行**时写出(见 `thread_manager::dispatch`),
/// 入队失败不写用户条目、不产生任何事件。可留痕的调用级失败(宿主 / 环境事实)仍在边界补一份运行
/// 错误诊断,返回串不带诊断引用。
/// 错误诊断;命令返回的就是 typed 变体本身。
///
/// 它是 DirectProject 唯一的命令入口:终端入口 `--direct-codex-chat`(它要保持 await 才能把回复打到
/// 终端上)已经退役,不要再为"手工跑一轮"新增第二条直接起回合的路径。
@@ -47,10 +47,10 @@ pub(crate) fn normalize_direct_client_turn_id(
#[tauri::command]
pub(crate) async fn enqueue_direct_codex_turn(
project_path: String,
user_item: DirectCodexUserItem,
user_item: UserItem,
creation_type: Option<String>,
client_turn_id: Option<String>,
) -> Result<(), DirectTurnEnqueueFailure> {
) -> Result<(), EnqueueError> {
let root = Path::new(project_path.trim());
let boundary_turn_id = client_turn_id.clone();
enqueue_direct_codex_turn_typed(root, user_item, creation_type, client_turn_id)
@@ -67,23 +67,23 @@ pub(crate) async fn enqueue_direct_codex_turn(
/// 回合事件与整轮都在放行那一侧。
async fn enqueue_direct_codex_turn_typed(
root: &Path,
user_item: DirectCodexUserItem,
user_item: UserItem,
creation_type: Option<String>,
client_turn_id: Option<String>,
) -> Result<(), DirectTurnError> {
) -> Result<(), EnqueueError> {
let turn_id = normalize_direct_client_turn_id(client_turn_id.as_deref())?;
recover_direct_taonier_regeneration_workflow_at(root).map_err(|error| {
DirectTurnError::HostStateUnavailable {
EnqueueError::HostStateUnavailable(HostStateUnavailable {
detail: redact_agent_runtime_error(
root,
&format!("恢复上一轮陶泥儿整包事务失败:{error}"),
500,
),
}
})
})?;
// 冻结是这条消息唯一的算片段 / 写盘时机:引用解析文本就此写进条目自身,放行只重投影。
let user_item = freeze_direct_codex_user_item(root, &user_item)
.map_err(|detail| DirectTurnError::InputRejected { detail })?;
.map_err(|detail| EnqueueError::InputRejected(InputRejected { detail }))?;
let user_prompt = direct_codex_user_item_to_prompt(&user_item);
check_direct_turn_preconditions(root, &user_prompt, creation_type.as_deref())?;
let thread_id = thread_id_for_project(root);
@@ -91,25 +91,27 @@ async fn enqueue_direct_codex_turn_typed(
// 准备——那是分钟级的活(`npm ci` + Vite 构建),还会在磁盘上留下产物。权威判据仍然是入队那一刻
// 临界区里的容量检查(下面 `enqueue_pending_turn`):这里只是快速失败,中间被别人的消息
// 挤满时那一条照样拦得住。
queue_has_room(pending_turn_count(&thread_id)).map_err(|_| DirectTurnError::QueueFull {
limit: MAX_PENDING_TURNS,
queue_has_room(pending_turn_count(&thread_id)).map_err(|_| {
EnqueueError::QueueFull(QueueFull {
limit: MAX_PENDING_TURNS,
})
})?;
// 创建类型来自结构化用户入口;实际工程和可信脚手架由宿主复核。
crate::environment_check::prepare_new_web_project_at(root, creation_type.as_deref())
.await
.map_err(|error| {
let detail = redact_agent_runtime_error(root, &error, 1800);
DirectTurnError::EnvironmentNotReady { detail }
EnqueueError::EnvironmentNotReady(EnvironmentNotReady { detail })
})?;
// 入队:到这里这一条已经过了全部检查,剩下的就是排队等放行。条目只带走它自己的事实
// (用户条目、创建类型、入队时刻),canonical 形状与 prompt 放行时从它重投影——放行没有失败出口。
let pending = PendingTurn::new(turn_id, user_item, creation_type, direct_now_ms());
let pending = PendingTurn::new(turn_id, user_item, creation_type, now_ms());
match enqueue_pending_turn(&thread_id, pending) {
Ok(_) => {}
Err(EnqueueRejection::QueueFull) => {
return Err(DirectTurnError::QueueFull {
return Err(EnqueueError::QueueFull(QueueFull {
limit: MAX_PENDING_TURNS,
})
}))
}
}
// 入队之后立刻踢一脚:队列空且没有回合在跑时,放行就是这一脚,用户点发送不必再等一个调度周期。
@@ -146,7 +148,23 @@ mod tests {
}
}
fn user_item(text: &str) -> DirectCodexUserItem {
/// 载荷里那份宿主原文:测试只关心"有没有原因 / 是哪一类",不关心前端怎么拼文案。
fn failure_detail(failure: &TurnFailure) -> String {
match failure {
TurnFailure::ProjectRootUnanchored(payload) => payload.cause.clone(),
TurnFailure::EnvironmentNotReady(payload) => payload.detail.clone(),
TurnFailure::HostStateUnavailable(payload) => payload.detail.clone(),
TurnFailure::ModelCallFailed(payload) => payload.detail.clone(),
TurnFailure::TransportClosed(payload) => payload.diagnostic.clone(),
TurnFailure::TimedOut(payload) => format!("{:?}", payload.deadline),
TurnFailure::TurnInterrupted(payload) => payload.detail.clone(),
TurnFailure::SuperErrorFromStringPlusStage(payload) => payload.detail.clone(),
TurnFailure::Unclassified(payload) => payload.detail.clone(),
TurnFailure::HostDropped => String::new(),
}
}
fn user_item(text: &str) -> UserItem {
serde_json::from_value(serde_json::json!({
"type": "message",
"role": "user",
@@ -188,10 +206,10 @@ mod tests {
.collect::<Vec<_>>();
assert_eq!(terminal.len(), 1, "一轮只许有一条终态:{events:?}");
let (status, failure, user_item_id) = terminal[0];
assert_eq!(status, "failed");
assert_eq!(*status, TurnCompletedStatus::Failed);
let failure = failure.as_ref().expect("失败终态必须带载荷");
assert!(
!failure.message.trim().is_empty(),
!failure_detail(failure).trim().is_empty(),
"放行之后的失败必须带上原因"
);
assert_eq!(user_item_id.as_deref(), Some("direct-codex:turn-1:user"));
@@ -315,20 +333,13 @@ mod tests {
.collect::<Vec<_>>();
assert_eq!(terminals.len(), 1, "一轮只许有一条终态:{events:?}");
let (status, failure) = terminals[0];
assert_eq!(status, "failed");
assert_eq!(*status, TurnCompletedStatus::Failed);
let failure = failure.as_ref().expect("失败终态必须带载荷");
assert!(
failure.message.contains("写入本项目对话历史失败"),
"{}",
failure.message
);
let detail = failure_detail(failure);
assert!(detail.contains("写入本项目对话历史失败"), "{detail}");
// 这一轮已经放行,所以走的是**回合失败**:入队失败那套 `direct-codex-failure:v2` 收口文案
// 不许出现在这里(它只属于可留痕的入队失败)。
assert!(
!failure.message.contains("direct-codex-failure"),
"{}",
failure.message
);
assert!(!detail.contains("direct-codex-failure"), "{detail}");
// 占用已释放:下一轮还能继续。
assert!(!crate::agent::thread_turn_is_active(&thread_id));
}
@@ -4236,8 +4236,9 @@ pub(crate) async fn start_direct_tool_bridge(
pub(crate) async fn start_external_client_tool_bridge(
root: &Path,
controlled_web_search: bool,
direct_turn_execution: bool,
) -> Result<DirectToolBridge, String> {
start_tool_bridge_for_source(root, controlled_web_search, false).await
start_tool_bridge_for_source(root, controlled_web_search, direct_turn_execution).await
}
async fn start_tool_bridge_for_source(
@@ -4421,6 +4422,115 @@ pub(super) fn drain_analytics_test_writer(
#[cfg(test)]
mod tests {
#[test]
fn browser_gameplay_results_reach_model_with_matching_viewports_and_screenshots() {
for passed in [false, true] {
let temp = tempfile::tempdir().unwrap();
let evidence = if passed {
super::super::direct_validation::tests::browser_evidence_fixture(temp.path())
} else {
super::super::direct_validation::tests::failed_browser_evidence_fixture(temp.path())
};
for viewport in &evidence.viewport_results {
image::RgbaImage::new(2, 2)
.save(&viewport.screenshot_path)
.unwrap();
}
let receipt = super::super::direct_validation::browser_tool_result(
temp.path(),
&evidence,
Some(crate::browser::BrowserPlaytestScenario::GenericV1),
1,
);
assert!(evidence.viewport_results.iter().all(|result| result.passed));
assert_eq!(receipt["passed"], passed);
let outcome = bridge_validation_result(
temp.path(),
Ok(receipt.clone()),
|report, screenshots| BrowserPlaytestError::PlaytestNotPassed {
report,
screenshots,
},
)
.map_err(|failure| {
let error = ToolCallError::from(&failure);
assert_eq!(error.error["PlaytestNotPassed"]["report"], receipt);
assert!(error.error["PlaytestNotPassed"]
.get("screenshots")
.is_none());
error
});
let state = direct_tool_bridge_state(temp.path().to_path_buf());
let response =
compose_direct_tool_outcome(&state, "agc_browser_playtest", "{}", false, outcome);
assert_eq!(response["isError"], !passed);
let text = response["content"][0]["text"].as_str().unwrap();
let model_receipt: Value =
serde_json::from_str(&text[text.find('{').unwrap()..]).unwrap();
assert_eq!(model_receipt, receipt);
assert_eq!(
response["content"]
.as_array()
.unwrap()
.iter()
.filter(|block| block["type"] == "image")
.count(),
2
);
let summary = model_receipt["summary"].as_str().unwrap();
for (index, entry) in evidence.viewport_playtests.iter().enumerate() {
let report = &model_receipt["gameplayResults"][index];
assert_eq!(
report["viewport"],
serde_json::to_value(entry.viewport).unwrap()
);
assert_eq!(report["passed"], entry.result.passed);
assert_eq!(
report["assertions"],
serde_json::to_value(&entry.result.assertions).unwrap()
);
assert_eq!(report["finalPhase"], "ready");
assert_eq!(
report["finalSequence"],
entry.result.final_sequence.unwrap()
);
let viewport = report["viewport"].as_str().unwrap();
let line = summary
.lines()
.find(|line| line.starts_with(&format!("{viewport}: gameplayPassed=")))
.unwrap();
assert!(line.starts_with(&format!(
"{viewport}: gameplayPassed={}",
entry.result.passed
)));
if let Some(reason) = entry.result.diagnostics.first() {
assert_eq!(report["diagnostics"][0], reason.as_str());
assert!(line.contains(reason));
}
if let Some(assertion) = entry
.result
.assertions
.iter()
.find(|assertion| !assertion.passed)
{
assert!(line.contains(&assertion.name));
}
}
}
}
#[tokio::test]
async fn external_client_bridge_can_be_enabled_for_a_direct_turn() {
let temporary = tempfile::tempdir().unwrap();
init_local_game_project_at(temporary.path(), "external-direct-turn", "外部直连回合")
.unwrap();
let bridge = start_external_client_tool_bridge(temporary.path(), false, true)
.await
.expect("direct Claude bridge");
assert!(bridge.state.direct_turn_execution);
drop(bridge);
}
#[tokio::test]
async fn host_cancellation_releases_queued_image_without_a_new_post() {
use std::sync::atomic::{AtomicBool, Ordering};
@@ -35,12 +35,16 @@ const EXTERNAL_MCP_RESPONSE_SUMMARY_MAX_CHARS: usize = 240;
const EXTERNAL_MCP_JOURNAL_MAX_BYTES: u64 = 8 * 1024 * 1024;
const EXTERNAL_MCP_JOURNAL_RELATIVE_PATH: &str = ".agent/conversations/codex-responses.jsonl";
static EXTERNAL_MCP_JOURNAL_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
static EXTERNAL_MCP_SERVER: OnceLock<Mutex<Option<ExternalMcpServer>>> = OnceLock::new();
static EXTERNAL_MCP_SERVER: OnceLock<
Mutex<std::collections::BTreeMap<PathBuf, ExternalMcpServer>>,
> = OnceLock::new();
tokio::task_local! {
static EXTERNAL_MCP_BRIDGE_URL: String;
}
pub(crate) struct ExternalMcpServer {
token: String,
_direct_turn_guard: Option<super::direct_tool_bridge::DirectToolBridgeTurnGuard>,
_bridge: super::direct_tool_bridge::DirectToolBridge,
task: tokio::task::JoinHandle<()>,
}
@@ -1759,6 +1763,21 @@ async fn handle_external_mcp_http_request(
pub(crate) async fn start_external_mcp_loopback(
root: &Path,
controlled_web_search: bool,
) -> Result<(String, String), String> {
start_external_mcp_loopback_with_mode(root, controlled_web_search, false).await
}
pub(crate) async fn start_external_mcp_loopback_for_direct_turn(
root: &Path,
controlled_web_search: bool,
) -> Result<(String, String), String> {
start_external_mcp_loopback_with_mode(root, controlled_web_search, true).await
}
async fn start_external_mcp_loopback_with_mode(
root: &Path,
controlled_web_search: bool,
direct_turn_execution: bool,
) -> Result<(String, String), String> {
let root = validate_direct_tools_project_root(root)?;
let session = current_platform_session()
@@ -1771,12 +1790,20 @@ pub(crate) async fn start_external_mcp_loopback(
let address = listener
.local_addr()
.map_err(|error| format!("读取客户端 MCP 地址失败:{error}"))?;
let bridge =
super::direct_tool_bridge::start_external_client_tool_bridge(&root, controlled_web_search)
.await?;
let bridge = super::direct_tool_bridge::start_external_client_tool_bridge(
&root,
controlled_web_search,
direct_turn_execution,
)
.await?;
let direct_turn_guard = if direct_turn_execution {
Some(bridge.begin_user_turn()?)
} else {
None
};
let state = ExternalMcpHttpState {
bridge_url: bridge.url().to_string(),
root,
root: root.clone(),
token: token.clone(),
session_user_id: session.user_id,
session_identity_generation: session.identity_generation,
@@ -1790,24 +1817,40 @@ pub(crate) async fn start_external_mcp_loopback(
let _ = axum::serve(listener, app).await;
});
let url = format!("http://127.0.0.1:{}{route}", address.port());
let registry = EXTERNAL_MCP_SERVER.get_or_init(|| Mutex::new(None));
let registry =
EXTERNAL_MCP_SERVER.get_or_init(|| Mutex::new(std::collections::BTreeMap::new()));
let mut guard = registry
.lock()
.map_err(|_| "客户端 MCP 服务注册表不可用".to_string())?;
if let Some(previous) = guard.take() {
drop(previous);
}
*guard = Some(ExternalMcpServer {
_bridge: bridge,
task,
});
guard.insert(
root,
ExternalMcpServer {
token: token.clone(),
_direct_turn_guard: direct_turn_guard,
_bridge: bridge,
task,
},
);
Ok((url, token))
}
pub(crate) fn stop_external_mcp_loopback() {
if let Some(registry) = EXTERNAL_MCP_SERVER.get() {
if let Ok(mut guard) = registry.lock() {
guard.take();
guard.clear();
}
}
}
pub(crate) fn stop_external_mcp_loopback_for_root(root: &Path, token: &str) {
let Ok(root) = root.canonicalize() else {
return;
};
if let Some(registry) = EXTERNAL_MCP_SERVER.get() {
if let Ok(mut guard) = registry.lock() {
if guard.get(&root).is_some_and(|server| server.token == token) {
guard.remove(&root);
}
}
}
}
@@ -2180,10 +2223,13 @@ mod tests {
.unwrap();
std::fs::create_dir(project.path().join(".agent")).unwrap();
std::fs::write(project.path().join(".agent/manifest.json"), "{}").unwrap();
let bridge =
super::super::direct_tool_bridge::start_direct_tool_bridge(project.path(), false)
.await
.unwrap();
let bridge = super::super::direct_tool_bridge::start_external_client_tool_bridge(
project.path(),
false,
false,
)
.await
.unwrap();
for enabled in [false, true, false, true] {
crate::builtin_plugins::set_enabled(
crate::builtin_plugins::AGC_GODOT_EDITOR_PLUGIN_ID,
@@ -2281,10 +2327,13 @@ mod tests {
let project = crate::tests::canonical_test_tempdir("builtin-editor-mcp-");
std::fs::create_dir_all(project.path().join(".agent")).unwrap();
std::fs::write(project.path().join(".agent/manifest.json"), "{}").unwrap();
let bridge =
super::super::direct_tool_bridge::start_direct_tool_bridge(project.path(), false)
.await
.unwrap();
let bridge = super::super::direct_tool_bridge::start_external_client_tool_bridge(
project.path(),
false,
false,
)
.await
.unwrap();
for (cocos_enabled, unity_enabled) in
[(false, false), (true, false), (false, true), (true, true)]
{
@@ -2419,10 +2468,13 @@ mod tests {
let project = crate::tests::canonical_test_tempdir("builtin-mcp-project-");
std::fs::create_dir_all(project.path().join(".agent")).unwrap();
std::fs::write(project.path().join(".agent/manifest.json"), "{}").unwrap();
let bridge =
super::super::direct_tool_bridge::start_direct_tool_bridge(project.path(), false)
.await
.unwrap();
let bridge = super::super::direct_tool_bridge::start_external_client_tool_bridge(
project.path(),
false,
false,
)
.await
.unwrap();
for enabled in [false, true, false, true] {
crate::builtin_plugins::set_enabled(
crate::builtin_plugins::AGC_COCOS_EDITOR_PLUGIN_ID,
@@ -3464,14 +3516,16 @@ mod tests {
"title": "工具链远端画布"
}]}}),
);
} else if route.starts_with("/api/editor/assets/library") {
} else if request_line.starts_with(&format!(
"GET /api/editor/assets/folders/{TOOL_CHAIN_ASSET_FOLDER_ID} "
)) {
tool_chain_write_json(
&mut stream,
"200 OK",
json!({"data": {"library": {"folders": [{
json!({"data": {"folder": {
"folderId": TOOL_CHAIN_ASSET_FOLDER_ID,
"label": "工具链远端目录"
}]}}}),
}}}),
);
} else if request_line.starts_with(expected_submission) {
tool_chain_write_json(
@@ -4017,6 +4071,9 @@ mod tests {
);
assert_eq!(requests.len(), 6, "{requests:?}");
assert!(requests.iter().any(|request| request.starts_with(&format!(
"GET /api/editor/assets/folders/{TOOL_CHAIN_ASSET_FOLDER_ID} "
))));
let submission = requests
.iter()
.find(|request| request.starts_with("POST /api/editor/images/edits "))
File diff suppressed because it is too large Load Diff

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