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
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:
@@ -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)}`,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 时,报告登录或权限状态异常并停止,交由用户处理登录和权限。",
|
||||
|
||||
+2
@@ -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"}],
|
||||
|
||||
+16
-19
@@ -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
Reference in New Issue
Block a user