修复AGC直连聊天持久化与生成竞态

持久化直连用户消息和最终安全回复,并在重开项目时恢复

补充直连聊天与工作台验收回归测试

直连美术生成和版本登记等待项目写锁,避免首轮生成被聊天保存阻断

同步直连 Runtime 实施计划
This commit is contained in:
kdletters
2026-08-17 20:11:22 +08:00
parent 286c402a4b
commit 94200a3cdb
6 changed files with 534 additions and 49 deletions
@@ -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
+159 -10
View File
@@ -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` 证据目录;完成回复展示可读验收摘要。