修复AGC直连聊天持久化与生成竞态
持久化直连用户消息和最终安全回复,并在重开项目时恢复 补充直连聊天与工作台验收回归测试 直连美术生成和版本登记等待项目写锁,避免首轮生成被聊天保存阻断 同步直连 Runtime 实施计划
This commit is contained in:
@@ -47,6 +47,35 @@ struct DirectTaonierArtAssetIdentity {
|
||||
canvas_project_id: String,
|
||||
}
|
||||
|
||||
fn direct_taonier_art_generation_runtime_context(
|
||||
root: &Path,
|
||||
output_path: &str,
|
||||
asset_kind: &str,
|
||||
) -> Result<PlatformArtGenerationRuntimeContext, String> {
|
||||
let project_id = read_manifest(&root.join(".agent/manifest.json"))?
|
||||
.project_id
|
||||
.trim()
|
||||
.to_string();
|
||||
if project_id.is_empty() {
|
||||
return Err("直连美术生成缺少项目身份,已拒绝创建平台请求".to_string());
|
||||
}
|
||||
let stage = match output_path {
|
||||
DIRECT_CODEX_ART_SPEC_ASSET_PATH => "art-spec",
|
||||
DIRECT_CODEX_BACKGROUND_ASSET_PATH => "game-background",
|
||||
DIRECT_CODEX_SPRITESHEET_ASSET_PATH => "art-spritesheet",
|
||||
_ => return Err("直连美术生成请求包含未声明的输出路径".to_string()),
|
||||
};
|
||||
Ok(PlatformArtGenerationRuntimeContext {
|
||||
agent_id: "direct-codex-art".to_string(),
|
||||
task_id: format!("direct-codex-art-{stage}"),
|
||||
session_id: project_id,
|
||||
run_id: stage.to_string(),
|
||||
source: "direct-codex".to_string(),
|
||||
action_id: format!("direct-taonier-{stage}"),
|
||||
action_fingerprint: format!("direct-taonier-art-v1:{asset_kind}:{stage}"),
|
||||
})
|
||||
}
|
||||
|
||||
fn direct_taonier_art_asset_identity(
|
||||
root: &Path,
|
||||
expected_path: &str,
|
||||
@@ -281,11 +310,17 @@ async fn generate_direct_taonier_art_asset_at(
|
||||
asset_label: asset_label.to_string(),
|
||||
replace_existing: root.join(output_path).is_file(),
|
||||
};
|
||||
let generated = if require_slices {
|
||||
generate_platform_art_asset_with_required_slices_at(root, prompt, &[], &options).await
|
||||
} else {
|
||||
generate_platform_art_asset_with_options_at(root, prompt, &[], &options).await
|
||||
}
|
||||
let runtime_context =
|
||||
direct_taonier_art_generation_runtime_context(root, output_path, asset_kind)?;
|
||||
let generated = generate_platform_art_asset_with_runtime_options_at(
|
||||
root,
|
||||
prompt,
|
||||
&[],
|
||||
&options,
|
||||
require_slices,
|
||||
&runtime_context,
|
||||
)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
format!("陶泥儿美术包生成失败({asset_label}),已在启动智能创作前终止:{error}")
|
||||
})?;
|
||||
@@ -375,7 +410,11 @@ async fn ensure_direct_taonier_art_package_at(
|
||||
.map(|path| (*path).to_string())
|
||||
.collect());
|
||||
}
|
||||
emit_direct_game_creator_progress(root, "art.spritesheet", "正在生成核心图集并切分四类运行时素材");
|
||||
emit_direct_game_creator_progress(
|
||||
root,
|
||||
"art.spritesheet",
|
||||
"正在生成核心图集并切分四类运行时素材",
|
||||
);
|
||||
generate_direct_taonier_art_asset_at(
|
||||
root,
|
||||
prompt,
|
||||
@@ -488,7 +527,10 @@ fn sync_direct_codex_project_outputs_at(
|
||||
}
|
||||
update_manifest_task_status_at(root, "code-prototype", GameCreationAppTaskStatus::Completed)?;
|
||||
if outputs_changed || manifest_requires_sync {
|
||||
let _lock = acquire_project_write_lock(root, "direct-codex.version")?;
|
||||
let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait(
|
||||
root,
|
||||
"direct-codex.version",
|
||||
)?;
|
||||
let version_revision = advance_agent_runtime_project_revision_locked(root)?;
|
||||
append_agent_game_iteration_version_at(root, version_revision)?;
|
||||
}
|
||||
|
||||
@@ -1306,6 +1306,59 @@ pub(crate) async fn generate_platform_art_asset_with_required_slices_at(
|
||||
commit_prepared_platform_art_asset_strict_slices_at(root, prepared, options, |_| Ok(()))
|
||||
}
|
||||
|
||||
/// Generates a platform art asset with a durable request identity owned by a
|
||||
/// caller outside the legacy Agent Runtime. Once the platform has accepted the
|
||||
/// request, later invocations only recover that same operation; they never
|
||||
/// create another billable generation for the same logical asset.
|
||||
pub(in crate::agent) async fn generate_platform_art_asset_with_runtime_options_at(
|
||||
root: &Path,
|
||||
prompt: &str,
|
||||
briefs: &[AgentGroupBrief],
|
||||
options: &PlatformArtAssetGenerationOptions,
|
||||
require_slices: bool,
|
||||
runtime_context: &PlatformArtGenerationRuntimeContext,
|
||||
) -> Result<GeneratedPlatformArtAsset, String> {
|
||||
if require_slices && options.asset_kind != "art-spritesheet" {
|
||||
return Err("严格游戏切片生成只允许 art-spritesheet 资产类型".to_string());
|
||||
}
|
||||
{
|
||||
// Direct Codex chat persists the user turn concurrently with the first
|
||||
// platform-art request. Both operations are short-lived project writes;
|
||||
// wait for the active writer instead of failing the whole generation on
|
||||
// the expected startup race.
|
||||
let recovery_lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait(
|
||||
root,
|
||||
"canvas.asset_generate.runtime.recover",
|
||||
)?;
|
||||
recover_interrupted_strict_platform_art_transaction_locked_at(root, &recovery_lock)?;
|
||||
}
|
||||
let prepared = request_platform_art_asset_with_runtime_options_at(
|
||||
root,
|
||||
prompt,
|
||||
briefs,
|
||||
options,
|
||||
Some(runtime_context),
|
||||
)
|
||||
.await?;
|
||||
let lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait(
|
||||
root,
|
||||
"canvas.asset_generate.runtime",
|
||||
)?;
|
||||
recover_interrupted_strict_platform_art_transaction_locked_at(root, &lock)?;
|
||||
advance_agent_runtime_project_revision_locked(root)?;
|
||||
let generated = if require_slices {
|
||||
commit_prepared_platform_art_asset_strict_slices_at(root, prepared, options, |_| Ok(()))?
|
||||
} else {
|
||||
commit_prepared_platform_art_asset_at(root, prepared, options, |_| Ok(()))?
|
||||
};
|
||||
remove_platform_art_generation_runtime_state_at(
|
||||
root,
|
||||
&runtime_context.agent_id,
|
||||
&runtime_context.run_id,
|
||||
)?;
|
||||
Ok(generated)
|
||||
}
|
||||
|
||||
pub(in crate::agent) async fn request_platform_art_asset_with_options_at(
|
||||
root: &Path,
|
||||
prompt: &str,
|
||||
@@ -6689,25 +6742,29 @@ mod canvas_generation_tests {
|
||||
request_sender
|
||||
.send(request.clone())
|
||||
.expect("capture accepted recovery request");
|
||||
if request.starts_with("GET /api/external/v1/generations/accepted-operation-1 ") {
|
||||
if request
|
||||
.starts_with("GET /api/runtime/external-generation/jobs/accepted-operation-1 ")
|
||||
{
|
||||
let body = serde_json::json!({
|
||||
"data": {
|
||||
"operationId": "accepted-operation-1",
|
||||
"status": "completed",
|
||||
"pollAfterMs": 0,
|
||||
"result": {
|
||||
"resource": {
|
||||
"resourceId": "persisted-resource-1",
|
||||
"projectId": "persisted-canvas-project",
|
||||
"imageSrc": server_download_url
|
||||
"job": {
|
||||
"operationId": "accepted-operation-1",
|
||||
"status": "completed",
|
||||
"pollAfterMs": 0,
|
||||
"result": {
|
||||
"resource": {
|
||||
"resourceId": "persisted-resource-1",
|
||||
"projectId": "persisted-canvas-project",
|
||||
"imageSrc": server_download_url
|
||||
},
|
||||
"warning": {
|
||||
"code": "unsupported-image-style",
|
||||
"reason": "已保留可用原图"
|
||||
},
|
||||
"sliceWarning": {
|
||||
"reason": "测试切片告警"
|
||||
}
|
||||
},
|
||||
"warning": {
|
||||
"code": "unsupported-image-style",
|
||||
"reason": "已保留可用原图"
|
||||
},
|
||||
"sliceWarning": {
|
||||
"reason": "测试切片告警"
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -6741,14 +6798,10 @@ mod canvas_generation_tests {
|
||||
.expect("reject unexpected accepted recovery request");
|
||||
}
|
||||
});
|
||||
let _config_guard = crate::tests::write_test_local_config(
|
||||
serde_json::json!({
|
||||
"editorApi": {
|
||||
"baseUrl": base_url,
|
||||
"apiKey": "accepted-recovery-key"
|
||||
}
|
||||
})
|
||||
.to_string(),
|
||||
let _platform_session = crate::platform_session::install_test_platform_session(
|
||||
"accepted-recovery-user",
|
||||
"accepted-recovery-key",
|
||||
&base_url,
|
||||
);
|
||||
let runtime_context = PlatformArtGenerationRuntimeContext {
|
||||
agent_id: "art-director".to_string(),
|
||||
@@ -6833,7 +6886,8 @@ mod canvas_generation_tests {
|
||||
2,
|
||||
"accepted recovery must only poll and download"
|
||||
);
|
||||
assert!(requests[0].starts_with("GET /api/external/v1/generations/accepted-operation-1 "));
|
||||
assert!(requests[0]
|
||||
.starts_with("GET /api/runtime/external-generation/jobs/accepted-operation-1 "));
|
||||
assert!(requests[1].starts_with("GET /download.png "));
|
||||
assert!(requests.iter().all(|request| !request.starts_with("POST ")));
|
||||
assert!(requests
|
||||
|
||||
@@ -131,6 +131,7 @@ import {
|
||||
persistSupervisorChatDraft,
|
||||
readInitialProjectPath,
|
||||
readSupervisorChatDraft,
|
||||
type ProjectSupervisorComponentProps,
|
||||
type WorkspaceLauncherProps,
|
||||
writeRecentWorkspace,
|
||||
} from './features/app-shell/model';
|
||||
@@ -254,6 +255,33 @@ const LEGACY_GAME_CHAT_AUTO_PREVIEW_AUTHORIZATION_STORAGE_KEY =
|
||||
const GAME_CHAT_AUTO_PREVIEW_AUTHORIZATION_STORAGE_KEY =
|
||||
'genarrative.game-chat.auto-preview-authorization.v2';
|
||||
const DIRECT_CODEX_PRODUCT_RUNTIME = true;
|
||||
const DIRECT_CODEX_CONVERSATION_MESSAGE_ID_PREFIX = 'direct-codex:';
|
||||
|
||||
function directCodexConversationMessageId(
|
||||
turnId: string,
|
||||
role: ChatMessage['role'],
|
||||
) {
|
||||
return `${DIRECT_CODEX_CONVERSATION_MESSAGE_ID_PREFIX}${turnId}:${role}`;
|
||||
}
|
||||
|
||||
function isPersistableDirectCodexConversationMessage(message: ChatMessage) {
|
||||
if (!message.runtimeOwned) {
|
||||
return false;
|
||||
}
|
||||
const messageId = message.messageId?.trim() ?? '';
|
||||
const roleSuffix = `:${message.role}`;
|
||||
if (
|
||||
!messageId.startsWith(DIRECT_CODEX_CONVERSATION_MESSAGE_ID_PREFIX) ||
|
||||
!messageId.endsWith(roleSuffix)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const turnId = messageId.slice(
|
||||
DIRECT_CODEX_CONVERSATION_MESSAGE_ID_PREFIX.length,
|
||||
-roleSuffix.length,
|
||||
);
|
||||
return /^[a-z0-9][a-z0-9-]{5,159}$/iu.test(turnId);
|
||||
}
|
||||
|
||||
type GameChatAutoPreviewAuthorization = {
|
||||
afterRevision: number;
|
||||
@@ -579,6 +607,8 @@ type AppProps = {
|
||||
gameChatOnly?: boolean;
|
||||
allowAdvancedExternalEditorConfig?: boolean;
|
||||
initialSupervisorMessage?: string;
|
||||
playRequest?: ProjectSupervisorComponentProps['playRequest'];
|
||||
onPlayRequestHandled?: ProjectSupervisorComponentProps['onPlayRequestHandled'];
|
||||
onManifestChange?: (
|
||||
projectPath: string,
|
||||
manifest: GameCreationAppManifest,
|
||||
@@ -601,6 +631,8 @@ export function App({
|
||||
gameChatOnly = false,
|
||||
allowAdvancedExternalEditorConfig = false,
|
||||
initialSupervisorMessage = '',
|
||||
playRequest = null,
|
||||
onPlayRequestHandled,
|
||||
onManifestChange,
|
||||
onPreviewChange,
|
||||
onAgentRuntimeSummariesChange,
|
||||
@@ -678,6 +710,7 @@ export function App({
|
||||
projectPath: initialProjectPath,
|
||||
prompt: initialSupervisorMessage.trim(),
|
||||
});
|
||||
const handledPlayRequestRef = useRef<string | null>(null);
|
||||
|
||||
function updateClientPreview(
|
||||
nextPreview: LocalPreviewResult | null,
|
||||
@@ -701,9 +734,25 @@ export function App({
|
||||
);
|
||||
const [chatAgentBusy, setChatAgentBusy] = useState(false);
|
||||
const [directCodexProgress, setDirectCodexProgress] = useState('');
|
||||
const directCodexConversationTurnSequenceRef = useRef(0);
|
||||
const [projectSupervisorSessionId, setProjectSupervisorSessionId] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
|
||||
function createDirectCodexConversationTurnId() {
|
||||
directCodexConversationTurnSequenceRef.current += 1;
|
||||
let randomId = '';
|
||||
try {
|
||||
randomId = globalThis.crypto?.randomUUID?.().trim() ?? '';
|
||||
} catch {
|
||||
// The timestamp and in-page sequence remain unique enough for a local
|
||||
// conversation append when WebView crypto is unavailable.
|
||||
}
|
||||
if (/^[a-z0-9][a-z0-9-]{5,159}$/iu.test(randomId)) {
|
||||
return randomId;
|
||||
}
|
||||
return `${Date.now().toString(36)}-${directCodexConversationTurnSequenceRef.current.toString(36)}`;
|
||||
}
|
||||
const [projectSupervisorRuntime, setProjectSupervisorRuntime] =
|
||||
useState<AgentRuntimeState | null>(null);
|
||||
const [projectSupervisorResponseStream, setProjectSupervisorResponseStream] =
|
||||
@@ -931,9 +980,9 @@ export function App({
|
||||
) => Promise<void>)
|
||||
| null
|
||||
>(null);
|
||||
const executeChatAgentReplyRef = useRef<(prompt: string) => Promise<void>>(
|
||||
async () => undefined,
|
||||
);
|
||||
const executeChatAgentReplyRef = useRef<
|
||||
(prompt: string, directConversationTurnId?: string) => Promise<void>
|
||||
>(async () => undefined);
|
||||
const agentConversationSavingRef = useRef(false);
|
||||
const agentConversationBackgroundBusyRef = useRef(false);
|
||||
const agentConversationLoadVersionRef = useRef(0);
|
||||
@@ -2107,7 +2156,8 @@ export function App({
|
||||
projectSupervisorOnly &&
|
||||
pendingMessages.every(
|
||||
(message) =>
|
||||
message.runtimeOwned ||
|
||||
(message.runtimeOwned &&
|
||||
!isPersistableDirectCodexConversationMessage(message)) ||
|
||||
isTransientProjectOpenMessage(message, nextProjectPath),
|
||||
)
|
||||
) {
|
||||
@@ -2170,7 +2220,10 @@ export function App({
|
||||
void (async () => {
|
||||
let wroteMessage = false;
|
||||
for (const [index, message] of pendingMessages.entries()) {
|
||||
if (message.runtimeOwned) {
|
||||
if (
|
||||
message.runtimeOwned &&
|
||||
!isPersistableDirectCodexConversationMessage(message)
|
||||
) {
|
||||
savedConversationCountRef.current = start + index + 1;
|
||||
continue;
|
||||
}
|
||||
@@ -2704,6 +2757,30 @@ export function App({
|
||||
]);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const nextProjectPath = localProject?.projectPath;
|
||||
if (
|
||||
!projectSupervisorOnly ||
|
||||
!playRequest ||
|
||||
!nextProjectPath ||
|
||||
playRequest.projectPath !== nextProjectPath
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const requestKey = `${playRequest.projectPath}\n${playRequest.requestId}`;
|
||||
if (handledPlayRequestRef.current === requestKey) {
|
||||
return;
|
||||
}
|
||||
handledPlayRequestRef.current = requestKey;
|
||||
onPlayRequestHandled?.(playRequest.requestId);
|
||||
queueRunLocalShortcut();
|
||||
}, [
|
||||
localProject?.projectPath,
|
||||
onPlayRequestHandled,
|
||||
playRequest,
|
||||
projectSupervisorOnly,
|
||||
]);
|
||||
|
||||
function queueStaticSmokeShortcut() {
|
||||
if (!requireChatProjectForUserAction()) {
|
||||
return;
|
||||
@@ -5874,7 +5951,10 @@ export function App({
|
||||
}
|
||||
}
|
||||
|
||||
async function executeChatAgentReply(prompt: string) {
|
||||
async function executeChatAgentReply(
|
||||
prompt: string,
|
||||
directConversationTurnId?: string,
|
||||
) {
|
||||
// Product default: send the conversation directly to Codex app-server.
|
||||
// The legacy Supervisor/harness path remains below for rollback and tests.
|
||||
if (directCodexProductRuntime) {
|
||||
@@ -5904,6 +5984,35 @@ export function App({
|
||||
}
|
||||
}
|
||||
if (directProjectPath && directInvoke) {
|
||||
const directAssistantMessageId = directConversationTurnId
|
||||
? directCodexConversationMessageId(
|
||||
directConversationTurnId,
|
||||
'assistant',
|
||||
)
|
||||
: undefined;
|
||||
const appendDirectUserMessageIfMissing = (
|
||||
current: ChatMessage[],
|
||||
): ChatMessage[] => {
|
||||
if (!directConversationTurnId) {
|
||||
return current;
|
||||
}
|
||||
const directUserMessageId = directCodexConversationMessageId(
|
||||
directConversationTurnId,
|
||||
'user',
|
||||
);
|
||||
return current.some((message) => message.messageId === directUserMessageId)
|
||||
? current
|
||||
: [
|
||||
...current,
|
||||
{
|
||||
role: 'user' as const,
|
||||
text: prompt,
|
||||
runtimeOwned: true,
|
||||
messageId: directUserMessageId,
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
];
|
||||
};
|
||||
setChatAgentBusy(true);
|
||||
setDirectCodexProgress('已提交需求,正在准备智能创作');
|
||||
setProjectSupervisorRuntimeError('');
|
||||
@@ -5917,11 +6026,14 @@ export function App({
|
||||
);
|
||||
if (localProjectPathRef.current === directProjectPath) {
|
||||
setMessages((current) => [
|
||||
...current,
|
||||
...appendDirectUserMessageIfMissing(current),
|
||||
{
|
||||
role: 'assistant',
|
||||
text: reply,
|
||||
runtimeOwned: true,
|
||||
...(directAssistantMessageId
|
||||
? { messageId: directAssistantMessageId }
|
||||
: {}),
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
]);
|
||||
@@ -5939,11 +6051,14 @@ export function App({
|
||||
if (localProjectPathRef.current === directProjectPath) {
|
||||
setProjectSupervisorRuntimeError(visibleMessage);
|
||||
setMessages((current) => [
|
||||
...current,
|
||||
...appendDirectUserMessageIfMissing(current),
|
||||
{
|
||||
role: 'assistant',
|
||||
text: visibleMessage,
|
||||
runtimeOwned: true,
|
||||
...(directAssistantMessageId
|
||||
? { messageId: directAssistantMessageId }
|
||||
: {}),
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
]);
|
||||
@@ -5964,6 +6079,14 @@ export function App({
|
||||
role: 'assistant',
|
||||
text: message,
|
||||
runtimeOwned: true,
|
||||
...(directConversationTurnId
|
||||
? {
|
||||
messageId: directCodexConversationMessageId(
|
||||
directConversationTurnId,
|
||||
'assistant',
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
]);
|
||||
@@ -6182,16 +6305,27 @@ export function App({
|
||||
return;
|
||||
}
|
||||
supervisorChatShouldFollowLatestRef.current = true;
|
||||
const directConversationTurnId = directCodexProductRuntime
|
||||
? createDirectCodexConversationTurnId()
|
||||
: undefined;
|
||||
setMessages((current) => [
|
||||
...current,
|
||||
{
|
||||
role: 'user',
|
||||
text: latch.prompt,
|
||||
runtimeOwned: true,
|
||||
...(directConversationTurnId
|
||||
? {
|
||||
messageId: directCodexConversationMessageId(
|
||||
directConversationTurnId,
|
||||
'user',
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
]);
|
||||
void executeChatAgentReplyRef.current(latch.prompt);
|
||||
void executeChatAgentReplyRef.current(latch.prompt, directConversationTurnId);
|
||||
}, [
|
||||
chatAgentBusy,
|
||||
directCodexProductRuntime,
|
||||
@@ -11175,6 +11309,9 @@ export function App({
|
||||
if (supervisorChatOnly || gameChatOnly) {
|
||||
supervisorChatShouldFollowLatestRef.current = true;
|
||||
}
|
||||
const directConversationTurnId = directCodexProductRuntime
|
||||
? createDirectCodexConversationTurnId()
|
||||
: undefined;
|
||||
setChatInput('');
|
||||
setMessages((current) => [
|
||||
...current,
|
||||
@@ -11182,10 +11319,18 @@ export function App({
|
||||
role: 'user',
|
||||
text: prompt,
|
||||
runtimeOwned: true,
|
||||
...(directConversationTurnId
|
||||
? {
|
||||
messageId: directCodexConversationMessageId(
|
||||
directConversationTurnId,
|
||||
'user',
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
]);
|
||||
void executeChatAgentReply(prompt);
|
||||
void executeChatAgentReply(prompt, directConversationTurnId);
|
||||
}
|
||||
|
||||
const visibleProfessionalAgentCards = agentStatusCards.filter(
|
||||
@@ -11311,14 +11456,18 @@ export function App({
|
||||
directCodexProductRuntime ? false : projectSupervisorNeedsUserInput
|
||||
}
|
||||
onCancelConfirmation={cancelUiCommandConfirmation}
|
||||
onCancelPendingCommand={handlePendingCommandCancel}
|
||||
onChatInputChange={setChatInput}
|
||||
onConfirmConfirmation={confirmUiCommand}
|
||||
onConfirmPendingCommand={() => void handlePendingCommandConfirm()}
|
||||
onScroll={handleConversationScroll}
|
||||
onShowEarlierMessages={showEarlierConversationMessages}
|
||||
onSubmit={handleProjectSupervisorOnlySubmit}
|
||||
pendingConfirmation={
|
||||
directCodexProductRuntime ? null : pendingUiConfirmation
|
||||
}
|
||||
pendingCommand={directCodexProductRuntime ? pendingCommand : null}
|
||||
projectPath={localProject?.projectPath ?? projectPath}
|
||||
transientReply={
|
||||
directCodexProductRuntime
|
||||
? directCodexProgress
|
||||
|
||||
@@ -1469,13 +1469,41 @@ export function registerHomeProjectCreationTests() {
|
||||
([command]) => command === 'resume_game_creator_agent_runtime_tasks',
|
||||
),
|
||||
).toHaveLength(0);
|
||||
const directConversationAppends = invoke.mock.calls.filter(
|
||||
([command, args]) =>
|
||||
command === 'append_local_conversation_message' &&
|
||||
(args as Record<string, unknown>)?.agentId === null,
|
||||
);
|
||||
expect(directConversationAppends).toHaveLength(2);
|
||||
const directConversationMessages = directConversationAppends.map(
|
||||
([, args]) => args as Record<string, unknown>,
|
||||
);
|
||||
expect(directConversationMessages.map((args) => args.message)).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
role: 'user',
|
||||
content: expect.stringContaining('初始意图:art / 做素材'),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
role: 'assistant',
|
||||
content: 'DIRECT_CODEX_TEST_OK',
|
||||
}),
|
||||
]),
|
||||
);
|
||||
const directConversationMessageIds = directConversationMessages.map((args) =>
|
||||
String(args.messageId),
|
||||
);
|
||||
expect(directConversationMessageIds).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.stringMatching(/^direct-codex:[a-z0-9-]+:user$/iu),
|
||||
expect.stringMatching(/^direct-codex:[a-z0-9-]+:assistant$/iu),
|
||||
]),
|
||||
);
|
||||
expect(
|
||||
invoke.mock.calls.filter(
|
||||
([command, args]) =>
|
||||
command === 'append_local_conversation_message' &&
|
||||
(args as Record<string, unknown>)?.agentId === null,
|
||||
),
|
||||
).toHaveLength(0);
|
||||
directConversationMessageIds[0].replace(/:(?:user|assistant)$/u, ''),
|
||||
).toBe(
|
||||
directConversationMessageIds[1].replace(/:(?:user|assistant)$/u, ''),
|
||||
);
|
||||
expect(invoke).not.toHaveBeenCalledWith(
|
||||
'generate_local_game_draft',
|
||||
expect.anything(),
|
||||
@@ -1549,15 +1577,45 @@ export function registerHomeProjectCreationTests() {
|
||||
'failed-direct-project',
|
||||
'直连失败项目',
|
||||
);
|
||||
const persistedMessages: Array<Record<string, unknown>> = [];
|
||||
const invoke = vi.fn(
|
||||
async (command: string, args?: Record<string, unknown>) => {
|
||||
if (command === 'get_local_game_manifest') {
|
||||
expect(args).toEqual({ projectPath });
|
||||
return manifest;
|
||||
}
|
||||
if (command === 'read_local_conversation') {
|
||||
return {
|
||||
path: `${projectPath}/.agent/conversations/project.jsonl`,
|
||||
agentId: null,
|
||||
sessionId: null,
|
||||
messages: [...persistedMessages],
|
||||
};
|
||||
}
|
||||
if (command === 'append_local_permission_log') {
|
||||
return {};
|
||||
}
|
||||
if (command === 'read_project_permission_policy') {
|
||||
return {
|
||||
path: '.agent/policy.json',
|
||||
policy: { deniedCommands: [], confirmCommands: [] },
|
||||
};
|
||||
}
|
||||
if (command === 'append_local_conversation_message') {
|
||||
const message = args?.message as Record<string, unknown>;
|
||||
persistedMessages.push({
|
||||
schemaVersion: 'game-creator-conversation.v1',
|
||||
...message,
|
||||
messageId: String(args?.messageId ?? ''),
|
||||
updatedAt: Number(message.updatedAt ?? persistedMessages.length + 1),
|
||||
});
|
||||
return {
|
||||
path: `${projectPath}/.agent/conversations/project.jsonl`,
|
||||
agentId: null,
|
||||
sessionId: null,
|
||||
messages: [...persistedMessages],
|
||||
};
|
||||
}
|
||||
if (command === 'chat_with_game_creator_direct_codex') {
|
||||
throw new Error('codex-app-server-error:unauthorized');
|
||||
}
|
||||
@@ -1578,6 +1636,39 @@ export function registerHomeProjectCreationTests() {
|
||||
expect(
|
||||
await screen.findByText('陶泥儿智能创作 鉴权失败,请检查 API Key 或登录态'),
|
||||
).not.toBeNull();
|
||||
await waitFor(() => {
|
||||
expect(persistedMessages).toHaveLength(2);
|
||||
});
|
||||
expect(persistedMessages).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ role: 'user', content: '生成一个游戏' }),
|
||||
expect.objectContaining({
|
||||
role: 'assistant',
|
||||
content: '陶泥儿智能创作 鉴权失败,请检查 API Key 或登录态',
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(JSON.stringify(persistedMessages)).not.toContain(
|
||||
'codex-app-server-error:unauthorized',
|
||||
);
|
||||
cleanup();
|
||||
|
||||
render(
|
||||
React.createElement(App, {
|
||||
initialProjectPath: projectPath,
|
||||
initialProjectManifest: manifest,
|
||||
projectSupervisorOnly: true,
|
||||
}),
|
||||
);
|
||||
expect(await screen.findByText('生成一个游戏')).not.toBeNull();
|
||||
expect(
|
||||
await screen.findByText('陶泥儿智能创作 鉴权失败,请检查 API Key 或登录态'),
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
invoke.mock.calls.filter(
|
||||
([command]) => command === 'chat_with_game_creator_direct_codex',
|
||||
),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -734,6 +734,10 @@ export function registerProjectWorkbenchFoundationTests() {
|
||||
}) as HTMLButtonElement;
|
||||
expect(runTab.disabled).toBe(false);
|
||||
expect(runTab.getAttribute('data-unavailable')).toBe('true');
|
||||
const playButton = screen.getByRole('button', {
|
||||
name: '播放',
|
||||
}) as HTMLButtonElement;
|
||||
expect(playButton.disabled).toBe(true);
|
||||
fireEvent.click(runTab);
|
||||
expect(runTab.getAttribute('aria-selected')).toBe('false');
|
||||
expect(
|
||||
@@ -3524,6 +3528,7 @@ export function registerProjectWorkbenchFoundationTests() {
|
||||
},
|
||||
);
|
||||
window.__TAURI__ = { core: { invoke } };
|
||||
const onPlay = vi.fn();
|
||||
|
||||
render(
|
||||
React.createElement(ProjectDevelopmentView, {
|
||||
@@ -3536,9 +3541,14 @@ export function registerProjectWorkbenchFoundationTests() {
|
||||
supervisor: React.createElement('div', null, '项目总控'),
|
||||
onHomeOpen: vi.fn(),
|
||||
onProjectsOpen: vi.fn(),
|
||||
onPlay,
|
||||
}),
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '播放' }));
|
||||
expect(onPlay).toHaveBeenCalledTimes(1);
|
||||
expect(screen.getByLabelText('运行表现层')).not.toBeNull();
|
||||
|
||||
const runTab = screen.getByRole('tab', {
|
||||
name: '运行',
|
||||
}) as HTMLButtonElement;
|
||||
@@ -3661,6 +3671,10 @@ export function registerProjectWorkbenchFoundationTests() {
|
||||
fireEvent.click(screen.getByRole('tab', { name: '运行' }));
|
||||
expect(screen.queryByTitle('远程预览拒绝测试 游戏运行画面')).toBeNull();
|
||||
expect(screen.getByText('客户端运行画面尚未载入')).not.toBeNull();
|
||||
expect(
|
||||
screen.getByText('点击顶部播放按钮后将在这里直接运行游戏'),
|
||||
).not.toBeNull();
|
||||
expect(screen.queryByText(/\/run|\/preview/)).toBeNull();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3913,6 +3927,55 @@ export function registerUserSurfaceBoundaryTests() {
|
||||
}
|
||||
|
||||
export function registerProjectSupervisorSurfaceTests() {
|
||||
it('routes a top workbench play request into the existing game.run_local confirmation', async () => {
|
||||
const projectPath = '/tmp/top-play-request';
|
||||
const supervisorHarness = createProjectSupervisorRuntimeHarness({
|
||||
projectPath,
|
||||
});
|
||||
const invoke = vi.fn(
|
||||
async (command: string, args?: Record<string, unknown>) => {
|
||||
if (command === 'inspect_local_project_directory') {
|
||||
return {
|
||||
projectPath,
|
||||
exists: true,
|
||||
isDirectory: true,
|
||||
isGameCreatorProject: true,
|
||||
projectName: '顶部播放请求',
|
||||
recentRunStatus: null,
|
||||
recentRunStopReason: null,
|
||||
};
|
||||
}
|
||||
return supervisorHarness.invoke(command, args);
|
||||
},
|
||||
);
|
||||
const handled = vi.fn();
|
||||
window.__TAURI__ = {
|
||||
core: { invoke },
|
||||
event: { listen: supervisorHarness.listen },
|
||||
};
|
||||
|
||||
render(
|
||||
React.createElement(App, {
|
||||
initialProjectPath: projectPath,
|
||||
projectSupervisorOnly: true,
|
||||
playRequest: { projectPath, requestId: 7 },
|
||||
onPlayRequestHandled: handled,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(
|
||||
await screen.findByText('game.run_local', {}, { timeout: 3_000 }),
|
||||
).not.toBeNull();
|
||||
expect(handled).toHaveBeenCalledWith(7);
|
||||
expect(
|
||||
screen.getByText(`运行自检并启动 ${projectPath}/game/`),
|
||||
).not.toBeNull();
|
||||
expect(invoke).not.toHaveBeenCalledWith(
|
||||
'start_local_game_preview',
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the direct Codex welcome surface free of legacy Supervisor status before the first message', async () => {
|
||||
const projectPath = '/tmp/launcher-empty-supervisor-game';
|
||||
const manifest = createGameCreationAppManifest(
|
||||
@@ -9235,7 +9298,7 @@ export function registerProjectSupervisorSurfaceTests() {
|
||||
invoke.mock.calls.filter(
|
||||
([command]) => command === 'read_project_permission_policy',
|
||||
),
|
||||
).toHaveLength(policyReadCountBeforeChat);
|
||||
).toHaveLength(policyReadCountBeforeChat + 1);
|
||||
});
|
||||
|
||||
it('keeps legacy professional Agent results readable while runtime controls stay hidden', async () => {
|
||||
|
||||
@@ -133,3 +133,89 @@
|
||||
1. direct Runtime 在不输出内部路径、Provider 或工具细节的前提下,复用 `game-creator-agent-progress` 发送安全的公开阶段:需求已接收、检查陶泥儿美术包、规范图、背景图、图集与切片、代码生成、项目版本登记和预览刷新。普通直连工作台按当前项目路径接收并即时显示该阶段;命令失败仍沿用现有安全错误映射,不把阶段进度伪装成已完成。
|
||||
2. 资源管理首次进入时主动请求最多 12 个可预览的非音频、非版本、非占位资源。请求仍服从三并发、96 项队列、48 项/64 MiB 缓存和原有身份失效规则;后续资源继续通过 `IntersectionObserver` 按滚动加载,详情与播放请求可提升优先级。首屏预取不改变权限策略,读取被拒绝时卡片必须保留安全错误状态。
|
||||
3. 验收必须同时证明:提交直连需求后立即可见“已提交需求,正在准备智能创作”,收到阶段事件后显示对应用户文案;真实普通 AGC 工作台首次打开既有项目时,无需进入详情即可显示游戏代码摘要以及陶泥儿规范图、场景背景图和核心图集的缩略图。
|
||||
|
||||
## 10. 平台已完成图集结果回收修复(2026-08-17)
|
||||
|
||||
### 现场证据
|
||||
|
||||
- 空项目的规范图与场景背景图已通过当前平台登录态生成、登记;核心 `grid-2x2` 图集任务在平台侧进入完成态后,客户端显示“美术包生成失败”。
|
||||
- 失败文本表明客户端收到了 `completed`,却没有拿到可消费的 `result`。这不是未登录、未配置平台生图或背景图失败,也不能通过重新提交图集来处理,否则会造成重复扣点。
|
||||
|
||||
### 修复计划
|
||||
|
||||
1. 核对平台账号模式的 `/api/runtime/external-generation/jobs/{operationId}` 完成响应、通用外部 API 完成响应和持久化 `result_payload_json` 的真实包络;统一把完成结果映射为客户端可消费的 `result`,保持 `grid-2x2`、四类切片、资源和资产身份合同不变。
|
||||
2. 对已进入 `accepted/running/completed` 的本地图集账本只轮询并恢复同一个远端任务;仅 `prepared` 且未取得接受凭证时才以原幂等键重放。任何“结果暂不可读”都保留账本并进入可恢复状态,不发起新的付费请求。
|
||||
3. 后端增加 completed 队列结果的契约测试;原生端增加平台 completed 包络的回收、四切片严格验收和不重提回归;前端继续将内部错误转换为安全、可行动的中文提示。
|
||||
4. 先对现有失败项目执行同一任务的状态恢复,确认不会产生新的生成提交;再用当前客户端新建项目跑完整规范图、背景图、图集、四切片、代码、版本与预览闭环。真实验收未通过前,不把本次修复标记为完成。
|
||||
|
||||
## 11. 直连聊天记录持久化(2026-08-17)
|
||||
|
||||
### 问题
|
||||
|
||||
- 普通直连创作会把用户输入、最终回复和失败提示都标记为运行时消息;项目对话保存器此前统一跳过这类消息。
|
||||
- 项目重新打开时已经会读取 `.agent/conversations/project.jsonl`,但直连问答从未写入该文件,因此客户端重启后只剩默认欢迎语。
|
||||
|
||||
### 现行契约
|
||||
|
||||
1. 每次直连提交生成一个仅用于项目对话的稳定回合标识,并把最终用户消息与最终助手消息分别写为 `direct-codex:<turn>:user`、`direct-codex:<turn>:assistant`。保存命令继续以 `messageId` 幂等,重试不得产生重复记录。
|
||||
2. 仅上述 role 与 ID 匹配的最终直连问答可以穿过运行时消息过滤并写入项目主对话;阶段进度、预览启动状态、流式中间内容及无稳定回合 ID 的运行时提示仍只保留在当前页面。
|
||||
3. 直连失败时,只保存已通过 `projectRuntimeVisibleError(...)` 转换的安全中文提示,禁止把 app-server、Provider、路径、认证或工具原始错误写入对话文件。
|
||||
4. 项目重新打开时继续复用既有主对话读取与 hydration。恢复聊天记录不代表 Codex app-server 的进程内 thread 能跨客户端恢复;下一条消息仍按现有直连会话策略发起。
|
||||
|
||||
### 验收
|
||||
|
||||
- AppSurface 覆盖成功与失败回合:用户消息和最终回复各保存一次、共享同一 turn 的不同 role ID;失败记录不含原始内部错误。
|
||||
- 重载同一项目时,`read_local_conversation` 返回已保存记录,聊天区恢复展示,不再次提交直连请求。
|
||||
- 真实桌面端验收:在同一项目完成一条直连问答,关闭并重新打开当前客户端后,用户输入和最终可见回复仍在项目聊天中。
|
||||
|
||||
### 并发写入约束
|
||||
|
||||
直连消息保存与首轮陶泥儿平台美术生成可能同时触发项目写锁。两者均为短时本地写入,直连美术生成的恢复锁、提交锁以及生成产物版本登记必须使用现有的有界等待锁;不能因为聊天记录保存占用项目锁的瞬时竞态而终止整轮游戏生成。
|
||||
|
||||
## 12. 顶部播放入口(2026-08-17)
|
||||
|
||||
### 产品边界
|
||||
|
||||
1. 项目工作台顶部提供唯一面向普通用户的“播放”按钮;运行空态不再引导用户输入 `/run` 或 `/preview`。
|
||||
2. 播放按钮只负责进入运行视图并排入现有 `game.run_local` 确认流程,不新增一套预览启动实现,也不直接拼接或打开外部 URL。
|
||||
3. 现有聊天命令解析暂时保留为兼容入口和回归测试依据,但不再作为工作台产品引导。
|
||||
|
||||
### 运行合同
|
||||
|
||||
- 点击播放后仍需经过项目权限确认。
|
||||
- 确认后继续执行 `game.static_smoke`、`start_local_game_preview`、预览状态同步、manifest / run trace 刷新和 loopback iframe 安全校验。
|
||||
- 没有可运行原型时按钮禁用;运行视图仍由现有 `runAvailable` 判定控制。
|
||||
|
||||
### 验收
|
||||
|
||||
- 可运行项目的工作台顶部显示“播放”,点击后进入运行视图并排入 `game.run_local`。
|
||||
- 没有可运行原型时“播放”禁用。
|
||||
- 运行空态文案只提示点击顶部播放按钮,不出现 `/run` 或 `/preview`。
|
||||
|
||||
## 13. 直连 Codex 的 LLM 自主试玩验收与柔性美术合同(2026-08-17)
|
||||
|
||||
### 13.1 分层边界
|
||||
|
||||
直连 Runtime 只把安全、权限、来源和不可逆副作用留在确定性门禁内:项目工作区与权限边界、凭据隔离、平台生成请求的幂等账本与未知结果恢复、持久文件事务、HTTP 完成包基本结构、图片下载/PNG 解码/可见像素、`source.kind=canvas`、资源身份与同 Canvas 项目关系。上述条件不满足时必须失败关闭,不能交给模型猜测或覆盖。
|
||||
|
||||
`grid-2x2`、固定四张切片、固定切片文件名、固定 `drawImage` 次数以及某一种 Canvas 代码形态不再是所有游戏的完成阻断合同。它们保留为“陶泥儿标准美术包”的推荐路径:平台返回完整透明主图集但切片后处理缺失时,仍可继续代码生成;已有切片可以被 Codex 优先使用,但客户端不得伪造切片或把缺失切片标记为已存在。只有平台主图集本身无法下载、解码、无可见像素、来源身份不可信或没有任何真实平台素材引用时才阻断。
|
||||
|
||||
### 13.2 同一 Codex thread 的有界自主验收
|
||||
|
||||
每个直连回合按以下最多三次 Codex turn 执行,不恢复 Supervisor、专业 Agent 或 harness:
|
||||
|
||||
1. 首轮由 Codex 生成或修改当前工作区文件。
|
||||
2. 客户端启动受限本地预览,使用真实 Chromium 在 desktop 和 mobile 两个固定视口采集页面加载、Canvas、控制台/异常、失败请求、可见文本、截图和有限的真实交互探针;direct 链路不使用 `BrowserPlaytestScenario::GenericV1` 等固定玩法状态机,试玩结果只作为模型判断证据。
|
||||
3. 客户端把脱敏、结构化的浏览器证据发送给同一 Codex thread。Codex 必须读取实际文件和证据,发现问题就直接修改并说明修复;若文件发生变化,客户端重新试玩。最新结果仍失败时最多再发送一次明确整改反馈,之后安全失败,不登记完成版本。
|
||||
|
||||
最终回复必须包含:检查过的文件、真实启动/试玩动作、desktop/mobile 观察、陶泥儿平台素材如何被实际使用、已修复问题和剩余风险。客户端只展示摘要,不把绝对路径、Token、签名 URL、Provider 原文或内部队列信息传给用户。
|
||||
|
||||
### 13.3 最低使用证明与完成条件
|
||||
|
||||
客户端只要求 `game/index.html` 存在,并能证明至少一个已登记、真实、平台来源的图片路径被游戏源码引用;不统计 `drawImage` 次数,不要求四类切片或固定代码形态。游戏质量、布局、可玩性、素材是否真的出现在画面中由 Codex 结合真实浏览器截图和结果自行判断。浏览器基础设施失败、页面无法加载、Canvas 完全不可见或出现未处理异常仍作为安全失败证据;其它质量问题交给同 thread 有界整改。
|
||||
|
||||
### 13.4 验收要求
|
||||
|
||||
- 定向 Rust 测试覆盖:完整主图集无切片可通过;来源/身份/PNG 解码/同 Canvas 约束仍失败关闭;源码合理引用任一平台素材可登记;无平台素材引用不能完成;系统提示词包含自主试玩与结构化报告要求。
|
||||
- fake app-server 测试覆盖:连续 direct turn 复用同一 thread,浏览器证据和整改反馈形成后续 `turn/start`,无 Supervisor/child/harness。
|
||||
- 真实客户端验收覆盖:当前 checkout 的 AGC 新建或打开直连项目,真实本地预览在 desktop/mobile 运行并至少执行一次可见交互;截图与结构化证据写入项目 `.agent/runtime` 证据目录;完成回复展示可读验收摘要。
|
||||
|
||||
Reference in New Issue
Block a user