完善总控纯聊天交互边界
在独立聊天窗口复用工具确认与结构化追问控件 按项目保留未发送草稿并展示Runtime恢复确认 补齐草稿、恢复、确认和追问的界面回归测试
This commit is contained in:
@@ -2971,9 +2971,6 @@ function ProjectSupervisorRuntimePanel({
|
||||
projectSupervisorCollaboratingAgentCount(runtime, runtimeByAgentId),
|
||||
)
|
||||
: '';
|
||||
const pendingToolAction = runtime?.pendingToolAction ?? null;
|
||||
const userInputRequest = runtime?.userInputRequest ?? null;
|
||||
const needsUserInput = agentRuntimeNeedsUserInput(runtime);
|
||||
|
||||
return (
|
||||
<section
|
||||
@@ -2987,6 +2984,40 @@ function ProjectSupervisorRuntimePanel({
|
||||
{compactProgress ? (
|
||||
<small aria-label="项目总控 Agent 进度">{compactProgress}</small>
|
||||
) : null}
|
||||
<ProjectSupervisorRuntimeControls
|
||||
runtime={runtime}
|
||||
controlBusy={controlBusy}
|
||||
onToolAction={onToolAction}
|
||||
onUserInput={onUserInput}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ProjectSupervisorRuntimeControls({
|
||||
runtime,
|
||||
controlBusy,
|
||||
onToolAction,
|
||||
onUserInput,
|
||||
}: {
|
||||
runtime: AgentRuntimeState | null;
|
||||
controlBusy: boolean;
|
||||
onToolAction: (decision: 'confirm' | 'reject') => void | Promise<void>;
|
||||
onUserInput: (
|
||||
request: AgentRuntimeUserInputRequest,
|
||||
responseId: string,
|
||||
answers: Record<string, string>,
|
||||
) => void | Promise<void>;
|
||||
}) {
|
||||
const pendingToolAction = runtime?.pendingToolAction ?? null;
|
||||
const userInputRequest = runtime?.userInputRequest ?? null;
|
||||
const needsUserInput = agentRuntimeNeedsUserInput(runtime);
|
||||
if (!pendingToolAction && !userInputRequest && !needsUserInput) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{userInputRequest ? (
|
||||
<AgentRuntimeUserInputCard
|
||||
key={`${userInputRequest.requestId}:${userInputRequest.responseId ?? 'pending'}`}
|
||||
@@ -3026,7 +3057,7 @@ function ProjectSupervisorRuntimePanel({
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3060,6 +3091,46 @@ function readInitialProjectPath() {
|
||||
return params.get('projectPath') ?? '';
|
||||
}
|
||||
|
||||
const SUPERVISOR_CHAT_DRAFT_STORAGE_PREFIX =
|
||||
'genarrative.supervisor-chat.draft';
|
||||
|
||||
function supervisorChatDraftStorageKey(projectPath: string) {
|
||||
return `${SUPERVISOR_CHAT_DRAFT_STORAGE_PREFIX}:${projectPath}`;
|
||||
}
|
||||
|
||||
function readSupervisorChatDraft(projectPath: string) {
|
||||
const normalizedProjectPath = projectPath.trim();
|
||||
if (!normalizedProjectPath) {
|
||||
return '';
|
||||
}
|
||||
try {
|
||||
return (
|
||||
window.sessionStorage.getItem(
|
||||
supervisorChatDraftStorageKey(normalizedProjectPath),
|
||||
) ?? ''
|
||||
);
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function persistSupervisorChatDraft(projectPath: string, draft: string) {
|
||||
const normalizedProjectPath = projectPath.trim();
|
||||
if (!normalizedProjectPath) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const storageKey = supervisorChatDraftStorageKey(normalizedProjectPath);
|
||||
if (draft) {
|
||||
window.sessionStorage.setItem(storageKey, draft);
|
||||
} else {
|
||||
window.sessionStorage.removeItem(storageKey);
|
||||
}
|
||||
} catch {
|
||||
// A disabled session store must not block the development chat surface.
|
||||
}
|
||||
}
|
||||
|
||||
const defaultRuntimeConfigDraft: GameCreatorAppConfig = {
|
||||
llm: {
|
||||
apiKey: '',
|
||||
@@ -17061,7 +17132,11 @@ export function App({
|
||||
);
|
||||
const [preview, setPreview] = useState<LocalPreviewResult | null>(null);
|
||||
const [previewStatus, setPreviewStatus] = useState('未启动');
|
||||
const [chatInput, setChatInput] = useState('');
|
||||
const [chatInput, setChatInput] = useState(() =>
|
||||
supervisorChatOnly && initialProjectPath
|
||||
? readSupervisorChatDraft(initialProjectPath)
|
||||
: '',
|
||||
);
|
||||
const [chatAgentBusy, setChatAgentBusy] = useState(false);
|
||||
const [projectSupervisorSessionId, setProjectSupervisorSessionId] = useState<
|
||||
string | null
|
||||
@@ -17612,6 +17687,13 @@ export function App({
|
||||
supervisorChatOnly,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!supervisorChatOnly) {
|
||||
return;
|
||||
}
|
||||
persistSupervisorChatDraft(initialProjectPath, chatInput);
|
||||
}, [chatInput, initialProjectPath, supervisorChatOnly]);
|
||||
|
||||
useEffect(() => {
|
||||
latestMessagesRef.current = messages;
|
||||
const invoke = resolveTauriInvoke();
|
||||
@@ -18367,6 +18449,12 @@ export function App({
|
||||
return '';
|
||||
}
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (
|
||||
message.includes('项目权限策略要求用户确认:agent.resume') ||
|
||||
message.includes('项目权限策略拒绝执行:agent.resume')
|
||||
) {
|
||||
return '';
|
||||
}
|
||||
if (isRuntimeConfigMissingError(message)) {
|
||||
requestRuntimeConfigOpen();
|
||||
}
|
||||
@@ -26619,6 +26707,11 @@ export function App({
|
||||
const projectSupervisorNeedsUserInput = agentRuntimeNeedsUserInput(
|
||||
projectSupervisorRuntime,
|
||||
);
|
||||
const projectSupervisorHasConversationControls = Boolean(
|
||||
projectSupervisorRuntime?.pendingToolAction ||
|
||||
projectSupervisorRuntime?.userInputRequest ||
|
||||
projectSupervisorNeedsUserInput,
|
||||
);
|
||||
const visibleAgentConversationMessages = latestVisibleItems(
|
||||
agentConversationMessages,
|
||||
agentConversationVisibleCount,
|
||||
@@ -26816,6 +26909,43 @@ export function App({
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{projectSupervisorHasConversationControls ? (
|
||||
<div className="supervisor-chat-only-runtime-controls">
|
||||
<ProjectSupervisorRuntimeControls
|
||||
runtime={projectSupervisorRuntime}
|
||||
controlBusy={chatAgentBusy}
|
||||
onToolAction={handleProjectSupervisorToolAction}
|
||||
onUserInput={handleProjectSupervisorUserInput}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{pendingUiConfirmation ? (
|
||||
<div className="supervisor-chat-only-runtime-controls">
|
||||
<div
|
||||
className="pending-command"
|
||||
aria-label="项目总控 Agent 待确认操作"
|
||||
>
|
||||
<span>
|
||||
{pendingUiConfirmation.commandId}
|
||||
<small>{pendingUiConfirmation.detail}</small>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
disabled={chatAgentBusy}
|
||||
onClick={cancelUiCommandConfirmation}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={chatAgentBusy}
|
||||
onClick={confirmUiCommand}
|
||||
>
|
||||
确认
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<form
|
||||
className="supervisor-chat-only-composer"
|
||||
|
||||
@@ -1589,6 +1589,31 @@ textarea {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.supervisor-chat-only-runtime-controls {
|
||||
align-self: stretch;
|
||||
width: 100%;
|
||||
max-width: 720px;
|
||||
}
|
||||
|
||||
.supervisor-chat-only-runtime-controls .agent-runtime-user-input {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding: 14px;
|
||||
border: 1px solid #d8dde5;
|
||||
border-radius: 8px;
|
||||
background: #f7f8fa;
|
||||
}
|
||||
|
||||
.supervisor-chat-only-runtime-controls .agent-runtime-user-input header {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.supervisor-chat-only-runtime-controls .pending-command,
|
||||
.supervisor-chat-only-runtime-controls .agent-runtime-user-input {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.supervisor-chat-only-composer {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 42px;
|
||||
|
||||
@@ -606,6 +606,7 @@ afterEach(() => {
|
||||
cleanup();
|
||||
window.history.pushState({}, '', '/');
|
||||
window.localStorage.clear();
|
||||
window.sessionStorage.clear();
|
||||
delete window.__TAURI__;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
@@ -6179,6 +6180,261 @@ describe('AI 游戏创作 App 界面边界', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps an unsent standalone Project Supervisor draft across window navigation reloads', async () => {
|
||||
const projectPath = '/tmp/supervisor-chat-only-draft';
|
||||
const harness = createProjectSupervisorRuntimeHarness({ projectPath });
|
||||
window.__TAURI__ = {
|
||||
core: { invoke: harness.invoke },
|
||||
event: { listen: harness.listen },
|
||||
};
|
||||
const renderSupervisorChat = () =>
|
||||
render(
|
||||
React.createElement(App, {
|
||||
initialProjectPath: projectPath,
|
||||
projectSupervisorOnly: true,
|
||||
supervisorChatOnly: true,
|
||||
}),
|
||||
);
|
||||
|
||||
renderSupervisorChat();
|
||||
const firstSurface = await screen.findByLabelText(
|
||||
'项目总控 Agent 纯聊天',
|
||||
);
|
||||
await within(firstSurface).findByLabelText('项目总控消息');
|
||||
fireEvent.change(
|
||||
within(firstSurface).getByLabelText('项目总控对话内容'),
|
||||
{ target: { value: '这条草稿还没有发送' } },
|
||||
);
|
||||
|
||||
cleanup();
|
||||
renderSupervisorChat();
|
||||
|
||||
const restoredInput = await screen.findByLabelText('项目总控对话内容');
|
||||
expect(restoredInput).toHaveProperty('value', '这条草稿还没有发送');
|
||||
});
|
||||
|
||||
it('shows and handles runtime recovery confirmation in the standalone Project Supervisor chat', async () => {
|
||||
const projectPath = '/tmp/supervisor-chat-only-resume';
|
||||
const harness = createProjectSupervisorRuntimeHarness({ projectPath });
|
||||
const invoke = vi.fn(
|
||||
async (command: string, args?: Record<string, unknown>) => {
|
||||
if (command === 'resume_game_creator_agent_runtime_tasks') {
|
||||
throw new Error('项目权限策略要求用户确认:agent.resume');
|
||||
}
|
||||
if (command === 'confirm_resume_game_creator_agent_runtime_tasks') {
|
||||
return [];
|
||||
}
|
||||
return harness.invoke(command, args);
|
||||
},
|
||||
);
|
||||
window.__TAURI__ = {
|
||||
core: { invoke },
|
||||
event: { listen: harness.listen },
|
||||
};
|
||||
render(
|
||||
React.createElement(App, {
|
||||
initialProjectPath: projectPath,
|
||||
projectSupervisorOnly: true,
|
||||
supervisorChatOnly: true,
|
||||
}),
|
||||
);
|
||||
|
||||
const surface = await screen.findByLabelText('项目总控 Agent 纯聊天');
|
||||
const detail = await within(surface).findByText(
|
||||
`恢复 ${projectPath} 中未完成的 Agent Runtime 任务`,
|
||||
);
|
||||
expect(within(surface).queryByText(/项目总控 Agent 恢复失败/)).toBeNull();
|
||||
const confirmation = detail.closest('.pending-command');
|
||||
expect(confirmation).not.toBeNull();
|
||||
|
||||
fireEvent.click(
|
||||
within(confirmation as HTMLElement).getByRole('button', {
|
||||
name: '确认',
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(invoke).toHaveBeenCalledWith(
|
||||
'confirm_resume_game_creator_agent_runtime_tasks',
|
||||
{ projectPath },
|
||||
);
|
||||
expect(
|
||||
within(surface).queryByText(
|
||||
`恢复 ${projectPath} 中未完成的 Agent Runtime 任务`,
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('confirms and rejects pending actions in the standalone Project Supervisor chat', async () => {
|
||||
const projectPath = '/tmp/supervisor-chat-only-confirmation';
|
||||
const runId = 'supervisor-chat-only-confirmation-run';
|
||||
const harness = createProjectSupervisorRuntimeHarness({
|
||||
projectPath,
|
||||
initialRuntime: {
|
||||
runId,
|
||||
status: 'waiting-for-confirmation',
|
||||
phase: 'waiting-for-confirmation',
|
||||
pendingToolAction: {
|
||||
actionId: 'standalone-action-confirm',
|
||||
actionFingerprint: 'standalone-fingerprint-confirm',
|
||||
tool: 'file.write',
|
||||
inputSummary: 'game/index.html',
|
||||
reason: null,
|
||||
requestedAt: 3000,
|
||||
},
|
||||
},
|
||||
});
|
||||
harness.setConfirmRuntime(
|
||||
harness.runtimeState({
|
||||
runId,
|
||||
status: 'waiting-for-confirmation',
|
||||
phase: 'waiting-for-confirmation',
|
||||
pendingToolAction: {
|
||||
actionId: 'standalone-action-reject',
|
||||
actionFingerprint: 'standalone-fingerprint-reject',
|
||||
tool: 'command.exec',
|
||||
inputSummary: 'npm test',
|
||||
reason: null,
|
||||
requestedAt: 4000,
|
||||
},
|
||||
}),
|
||||
);
|
||||
harness.setRejectRuntime(
|
||||
harness.runtimeState({
|
||||
runId,
|
||||
status: 'running',
|
||||
phase: 'planning',
|
||||
pendingToolAction: null,
|
||||
}),
|
||||
);
|
||||
window.__TAURI__ = {
|
||||
core: { invoke: harness.invoke },
|
||||
event: { listen: harness.listen },
|
||||
};
|
||||
render(
|
||||
React.createElement(App, {
|
||||
initialProjectPath: projectPath,
|
||||
projectSupervisorOnly: true,
|
||||
supervisorChatOnly: true,
|
||||
}),
|
||||
);
|
||||
|
||||
const surface = await screen.findByLabelText('项目总控 Agent 纯聊天');
|
||||
let pendingAction = await within(surface).findByLabelText(
|
||||
'项目总控 Agent 待确认动作',
|
||||
);
|
||||
expect(within(pendingAction).getByText('file.write')).not.toBeNull();
|
||||
expect(within(pendingAction).getByText('game/index.html')).not.toBeNull();
|
||||
fireEvent.click(
|
||||
within(pendingAction).getByRole('button', { name: '确认' }),
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(harness.invoke).toHaveBeenCalledWith(
|
||||
'confirm_game_creator_agent_runtime_task',
|
||||
{
|
||||
projectPath,
|
||||
agentId: 'project-supervisor',
|
||||
runId,
|
||||
actionId: 'standalone-action-confirm',
|
||||
note: '用户已确认待执行工具动作',
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
pendingAction = await within(surface).findByLabelText(
|
||||
'项目总控 Agent 待确认动作',
|
||||
);
|
||||
expect(within(pendingAction).getByText('command.exec')).not.toBeNull();
|
||||
expect(within(pendingAction).getByText('npm test')).not.toBeNull();
|
||||
fireEvent.click(
|
||||
within(pendingAction).getByRole('button', { name: '拒绝' }),
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(harness.invoke).toHaveBeenCalledWith(
|
||||
'reject_game_creator_agent_runtime_task',
|
||||
{
|
||||
projectPath,
|
||||
agentId: 'project-supervisor',
|
||||
runId,
|
||||
actionId: 'standalone-action-reject',
|
||||
note: '用户已拒绝待执行工具动作',
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('answers structured questions in the standalone Project Supervisor chat', async () => {
|
||||
const projectPath = '/tmp/supervisor-chat-only-user-input';
|
||||
const sessionId = 'supervisor-chat-only-user-input-session';
|
||||
const runId = 'supervisor-chat-only-user-input-run';
|
||||
const request = agentRuntimeUserInputRequest({
|
||||
agentId: 'project-supervisor',
|
||||
sessionId,
|
||||
runId,
|
||||
});
|
||||
const harness = createProjectSupervisorRuntimeHarness({
|
||||
projectPath,
|
||||
sessionId,
|
||||
initialRuntime: {
|
||||
runId,
|
||||
status: 'waiting-for-user-input',
|
||||
phase: 'waiting-for-user-input',
|
||||
currentTask: '准备首版角色规范图',
|
||||
currentAction: '等待用户补充关键信息',
|
||||
waitingOn: '你的澄清回答',
|
||||
nextStep: '提交全部回答后继续同一 Run',
|
||||
userInputRequest: request,
|
||||
updatedAt: 6000,
|
||||
},
|
||||
});
|
||||
window.__TAURI__ = {
|
||||
core: { invoke: harness.invoke },
|
||||
event: { listen: harness.listen },
|
||||
};
|
||||
render(
|
||||
React.createElement(App, {
|
||||
initialProjectPath: projectPath,
|
||||
projectSupervisorOnly: true,
|
||||
supervisorChatOnly: true,
|
||||
}),
|
||||
);
|
||||
|
||||
const surface = await screen.findByLabelText('项目总控 Agent 纯聊天');
|
||||
const card = await within(surface).findByLabelText('Needs input');
|
||||
expect(within(card).getByText('1. 美术方向')).not.toBeNull();
|
||||
expect(
|
||||
within(card).getByText('首版角色规范图采用哪种美术方向?'),
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
within(card).getByText('优先验证轮廓与动作可读性。'),
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
(within(surface).getByLabelText('项目总控对话内容') as HTMLTextAreaElement)
|
||||
.disabled,
|
||||
).toBe(true);
|
||||
|
||||
fireEvent.click(within(card).getByRole('button', { name: /像素风/ }));
|
||||
fireEvent.click(within(card).getByRole('button', { name: '提交回答' }));
|
||||
await waitFor(() => {
|
||||
expect(harness.invoke).toHaveBeenCalledWith(
|
||||
'answer_game_creator_agent_runtime_user_input',
|
||||
{
|
||||
projectPath,
|
||||
agentId: 'project-supervisor',
|
||||
runId,
|
||||
actionId: request.actionId,
|
||||
requestId: request.requestId,
|
||||
responseId: expect.stringMatching(/^app-user-input-/),
|
||||
answers: { visual_direction: '像素风' },
|
||||
},
|
||||
);
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(within(surface).queryByLabelText('Needs input')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('opens an existing project into the active Supervisor Session, restores history, then starts and steers the same run', async () => {
|
||||
const projectPath = '/tmp/launcher-supervisor-game';
|
||||
const manifest = createGameCreationAppManifest(
|
||||
|
||||
Reference in New Issue
Block a user