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:
@@ -164,6 +164,10 @@ module.exports = {
|
||||
'server-rs/target-*',
|
||||
'apps/desktop-shell/src-tauri/target',
|
||||
'apps/ai-game-creator-shell/src/features/ui-editor/types/**',
|
||||
// ts-rs 生成绑定:不接受 eslint --fix 二次改写,必须与原始输出逐字节一致
|
||||
'packages/shared/src/contracts/generated/**',
|
||||
'apps/ai-game-creator-shell/src/view/project-development/chat/generated/**',
|
||||
'apps/ai-game-creator-shell/src/services/generated/**',
|
||||
'apps/ai-game-creator-shell/src/features/project-workspace/generated/**',
|
||||
'target',
|
||||
'src/main.tsx',
|
||||
|
||||
+7
-2
@@ -24,5 +24,10 @@
|
||||
*.anim text
|
||||
*.controller text
|
||||
|
||||
# Rust ts-rs 生成的共享契约:保留在仓库中供 TS 消费,但不作为手写源文件统计。
|
||||
packages/shared/src/contracts/generated/** linguist-generated=true
|
||||
# ts-rs 生成绑定:保留在仓库中供 TS 消费,但不作为手写源文件统计。
|
||||
# ts-rs 原始输出在多行对象 / 枚举变体后带行尾空格,生成目录统一豁免行尾空白检查;
|
||||
# 一致性由 `npm run check:generated-bindings` 逐字节比对原始输出来保证。
|
||||
packages/shared/src/contracts/generated/** linguist-generated=true whitespace=-trailing-space
|
||||
apps/ai-game-creator-shell/src/features/ui-editor/types/** linguist-generated=true whitespace=-trailing-space
|
||||
apps/ai-game-creator-shell/src/view/project-development/chat/generated/** linguist-generated=true whitespace=-trailing-space
|
||||
apps/ai-game-creator-shell/src/services/generated/** linguist-generated=true whitespace=-trailing-space
|
||||
|
||||
@@ -5,7 +5,11 @@ node_modules
|
||||
# ts-rs 生成产物:必须与 Rust 声明逐字节一致,`check:generated-bindings` 会重新生成并比对。
|
||||
packages/shared/src/contracts/generated/
|
||||
public/Icons
|
||||
# ts-rs 生成绑定:一律以原始输出提交,禁止 prettier 二次改写
|
||||
packages/shared/src/contracts/generated/
|
||||
apps/ai-game-creator-shell/src/features/ui-editor/types/
|
||||
apps/ai-game-creator-shell/src/view/project-development/chat/generated/
|
||||
apps/ai-game-creator-shell/src/services/generated/
|
||||
apps/ai-game-creator-shell/src-tauri/resources/agc-skills/
|
||||
media
|
||||
*.log
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user