feat: organize project conversations and add coding teacher side chat

This commit is contained in:
2026-09-22 12:30:26 +08:00
parent 2f82b9f79c
commit 9242a40d87
45 changed files with 2987 additions and 1137 deletions

View File

@@ -1,60 +0,0 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { AgentCreationDialog } from '@/components/coding/AgentCreationDialog';
import type { CodingProjectAgent } from '@/types/coding-project';
const agent: CodingProjectAgent = {
id: 'builder',
avatarId: 'avatar-01',
roleName: '实现者',
name: 'Builder',
builtIn: false,
enabled: true,
model: { accountId: 'account-a', modelId: 'model-a', thinkingLevel: 'medium' },
modelResolution: 'resolved',
skillIds: ['data-service'],
responsibility: {
mission: 'Implement changes', owns: [], boundaries: [], collaborators: [], principles: [],
},
prompt: '',
archivedAt: null,
pinned: false,
createdAt: '2026-08-27T00:00:00Z',
updatedAt: '2026-08-27T00:00:00Z',
};
describe('AgentCreationDialog plugin Skill availability', () => {
it('shows a retained disabled assignment as unavailable and persists it unchanged', async () => {
const onUpdate = vi.fn(async () => undefined);
render(<AgentCreationDialog
open
onOpenChange={vi.fn()}
agent={agent}
modelOptions={[{
key: JSON.stringify(['account-a', 'model-a']),
accountId: 'account-a',
modelId: 'model-a',
label: 'Account A / Model A',
availableThinkingLevels: null,
}]}
skills={[{
id: 'data-service',
name: 'Data Service',
description: 'Project data tools',
available: false,
effective: false,
}]}
onUpdate={onUpdate}
/>);
const checkbox = screen.getByRole('checkbox', { name: '绑定插件能力:Data Service' });
expect(checkbox).toBeChecked();
expect(checkbox).toBeDisabled();
expect(screen.getByText('插件未启用;现有分配会保留,但当前不可用。')).toBeVisible();
fireEvent.click(screen.getByRole('button', { name: '保存智能体' }));
await waitFor(() => expect(onUpdate).toHaveBeenCalledWith(
expect.objectContaining({ skillIds: ['data-service'] }),
));
});
});

View File

@@ -43,7 +43,8 @@ function withForkSource(
};
}
const runtimeSnapshot = deferred<ConversationSnapshot>();
const navigate = vi.hoisted(() => vi.fn());
vi.mock('react-router-dom', async (importOriginal) => ({ ...await importOriginal<typeof import('react-router-dom')>(), useNavigate: () => navigate }));
class FakeEventSource {
onopen: ((event: Event) => void) | null = null;
@@ -52,7 +53,6 @@ class FakeEventSource {
addEventListener = vi.fn();
}
const source = new FakeEventSource();
const project: CodingProjectSummary = {
id: 'project-1',
name: 'Local project',
@@ -216,18 +216,20 @@ describe('CodingChatPanel first Conversation', () => {
const { createLocalConversationSnapshot } = await import('@/pages/Chat/coding-chat-snapshot');
conversationApi.snapshot.mockResolvedValue({ ...createLocalConversationSnapshot(project.id, conversation),
run: { status: 'running', runId: 'run-1' } });
const { CodingChatPanel } = await import('@/pages/Chat/CodingChatPanel');
const { CodingChatPanel: CoreChatPanel } = await import('@/pages/Chat/CodingChatPanel');
const { CodingProjectConversations } = await import('@/pages/Chat/CodingProjectConversations');
const CodingChatPanel = (props: import('@/pages/Chat/CodingChatPanel').CodingChatPanelProps) => <><CodingProjectConversations projectId={project.id} /><CoreChatPanel {...props} /></>;
const { codingConversationStore } = await import('@/stores/coding-conversations');
render(<CodingChatPanel />);
const menu = await screen.findByRole('button', { name: '对话操作:新对话' });
await waitFor(() => expect(codingConversationStore.getState().selectedConversationId).toBe(conversation.id));
fireEvent.keyDown(menu, { key: 'Enter' });
fireEvent.click(await screen.findByRole('menuitem', { name: '归档(任务继续运行)' }));
await screen.findByText('没有最近会话,可新建对话或查看归档');
await screen.findByTestId('coding-conversation-pending');
expect(projectApi.create).not.toHaveBeenCalled();
expect(conversationApi.abort).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('button', { name: '已归档(1)' }));
fireEvent.click(within(screen.getByTestId('coding-conversation-sidebar')).getByRole('button', { name: '新对话', exact: true }));
fireEvent.click(screen.getByRole('button', { name: '查看归档对话' }));
fireEvent.click(within(screen.getByTestId('project-conversations')).getByRole('button', { name: '新对话', exact: true }));
await screen.findByText('此对话已归档,任务仍在运行。');
expect(screen.queryByRole('button', { name: '发送', exact: true })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '恢复对话' }));
@@ -245,7 +247,9 @@ describe('CodingChatPanel first Conversation', () => {
conversationApi.events.mockResolvedValue(new FakeEventSource() as unknown as EventSource);
const { createLocalConversationSnapshot } = await import('@/pages/Chat/coding-chat-snapshot');
conversationApi.snapshot.mockResolvedValue(createLocalConversationSnapshot(project.id, conversation));
const { CodingChatPanel } = await import('@/pages/Chat/CodingChatPanel');
const { CodingChatPanel: CoreChatPanel } = await import('@/pages/Chat/CodingChatPanel');
const { CodingProjectConversations } = await import('@/pages/Chat/CodingProjectConversations');
const CodingChatPanel = (props: import('@/pages/Chat/CodingChatPanel').CodingChatPanelProps) => <><CodingProjectConversations projectId={project.id} /><CoreChatPanel {...props} /></>;
render(<CodingChatPanel />);
fireEvent.keyDown(await screen.findByRole('button', { name: '对话操作:旧标题' }), { key: 'Enter' });
fireEvent.click(await screen.findByRole('menuitem', { name: '重命名' }));
@@ -267,13 +271,15 @@ describe('CodingChatPanel first Conversation', () => {
config: { ...configForAgents([]), initialized: false },
});
projectApi.conversations.mockResolvedValue([]);
const { CodingChatPanel } = await import('@/pages/Chat/CodingChatPanel');
const { CodingChatPanel: CoreChatPanel } = await import('@/pages/Chat/CodingChatPanel');
const { CodingProjectConversations } = await import('@/pages/Chat/CodingProjectConversations');
const CodingChatPanel = (props: import('@/pages/Chat/CodingChatPanel').CodingChatPanelProps) => <><CodingProjectConversations projectId={project.id} /><CoreChatPanel {...props} /></>;
render(<CodingChatPanel onOpenProjectSettings={openProjectSettings} />);
expect(await screen.findByTestId('coding-chat-empty-agent')).toHaveTextContent('项目已准备好');
expect(projectApi.create).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('button', { name: '创建项目智能体' }));
fireEvent.click(within(screen.getByTestId('coding-chat-empty-agent')).getByRole('button', { name: '项目设置' }));
expect(openProjectSettings).toHaveBeenCalledOnce();
});
@@ -282,7 +288,9 @@ describe('CodingChatPanel first Conversation', () => {
const onCreateProject = vi.fn();
const onOpenProject = vi.fn();
const onOpenProjectSettings = vi.fn();
const { CodingChatPanel } = await import('@/pages/Chat/CodingChatPanel');
const { CodingChatPanel: CoreChatPanel } = await import('@/pages/Chat/CodingChatPanel');
const { CodingProjectConversations } = await import('@/pages/Chat/CodingProjectConversations');
const CodingChatPanel = (props: import('@/pages/Chat/CodingChatPanel').CodingChatPanelProps) => <><CodingProjectConversations projectId={project.id} /><CoreChatPanel {...props} /></>;
render(
<CodingChatPanel
@@ -311,44 +319,22 @@ describe('CodingChatPanel first Conversation', () => {
expect(onOpenProjectSettings).not.toHaveBeenCalled();
});
it('makes the first-Conversation textarea editable while runtime metadata is held', async () => {
it('keeps an empty project editable without creating a conversation until first send', async () => {
projectApi.list.mockResolvedValue({ projects: [project], activeProjectId: project.id });
projectApi.config.mockResolvedValue({ project, config });
projectApi.conversations.mockResolvedValue([]);
projectApi.create.mockResolvedValue(conversation);
conversationApi.snapshot.mockReturnValue(runtimeSnapshot.promise);
conversationApi.events.mockResolvedValue(source as unknown as EventSource);
conversationApi.submit.mockRejectedValue(new Error('Provider is intentionally not used'));
conversationApi.recover.mockResolvedValue(undefined);
conversationApi.events.mockResolvedValue(new FakeEventSource() as unknown as EventSource);
const { CodingChatPanel } = await import('@/pages/Chat/CodingChatPanel');
const { createLocalConversationSnapshot } = await import(
'@/pages/Chat/coding-chat-snapshot'
);
const view = render(<CodingChatPanel />);
const { createLocalConversationSnapshot } = await import('@/pages/Chat/coding-chat-snapshot');
conversationApi.snapshot.mockResolvedValue(createLocalConversationSnapshot(project.id, conversation));
render(<CodingChatPanel />);
const textbox = await screen.findByRole('textbox');
expect(screen.getByRole('heading', { name: '你想让麦洛和你一起构建什么?' })).toBeInTheDocument();
expect(screen.getByRole('img', { name: '麦洛 M 标识' })).toBeInTheDocument();
expect(textbox).toBeEnabled();
expect(projectApi.create).toHaveBeenCalledOnce();
expect(conversationApi.snapshot).toHaveBeenCalledWith(conversation.id);
fireEvent.change(textbox, { target: { value: 'First prompt' } });
const sendButton = screen.getByRole('button', { name: '发送' });
await waitFor(() => expect(sendButton).toBeEnabled());
expect(textbox).toHaveValue('First prompt');
expect(conversationApi.submit).not.toHaveBeenCalled();
await act(async () => {
runtimeSnapshot.resolve({
...createLocalConversationSnapshot(project.id, conversation),
worker: { status: 'ready', generation: 1 },
cursor: { workerGeneration: 1, seq: 0 },
});
await runtimeSnapshot.promise;
});
view.unmount();
expect(source.close).toHaveBeenCalled();
expect(projectApi.create).not.toHaveBeenCalled();
expect(conversationApi.snapshot).not.toHaveBeenCalled();
fireEvent.change(textbox, {target:{value:'First prompt'}});
fireEvent.click(screen.getByRole('button',{name:'发送'}));
await waitFor(()=>expect(projectApi.create).toHaveBeenCalledOnce());
});
it('rehydrates a preselected stale Conversation when the programming view mounts', async () => {
@@ -357,7 +343,9 @@ describe('CodingChatPanel first Conversation', () => {
projectApi.conversations.mockResolvedValue([conversation]);
conversationApi.events.mockResolvedValue(new FakeEventSource() as unknown as EventSource);
conversationApi.recover.mockResolvedValue(undefined);
const { CodingChatPanel } = await import('@/pages/Chat/CodingChatPanel');
const { CodingChatPanel: CoreChatPanel } = await import('@/pages/Chat/CodingChatPanel');
const { CodingProjectConversations } = await import('@/pages/Chat/CodingProjectConversations');
const CodingChatPanel = (props: import('@/pages/Chat/CodingChatPanel').CodingChatPanelProps) => <><CodingProjectConversations projectId={project.id} /><CoreChatPanel {...props} /></>;
const { createLocalConversationSnapshot } = await import('@/pages/Chat/coding-chat-snapshot');
const { codingConversationStore } = await import('@/stores/coding-conversations');
const { codingWorkspaceStore } = await import('@/stores/coding-workspace');
@@ -427,7 +415,9 @@ describe('CodingChatPanel first Conversation', () => {
.mockResolvedValueOnce(firstSource as unknown as EventSource)
.mockResolvedValueOnce(resumedSource as unknown as EventSource);
conversationApi.recover.mockResolvedValue(undefined);
const { CodingChatPanel } = await import('@/pages/Chat/CodingChatPanel');
const { CodingChatPanel: CoreChatPanel } = await import('@/pages/Chat/CodingChatPanel');
const { CodingProjectConversations } = await import('@/pages/Chat/CodingProjectConversations');
const CodingChatPanel = (props: import('@/pages/Chat/CodingChatPanel').CodingChatPanelProps) => <><CodingProjectConversations projectId={project.id} /><CoreChatPanel {...props} /></>;
const { createLocalConversationSnapshot } = await import('@/pages/Chat/coding-chat-snapshot');
const { codingConversationStore } = await import('@/stores/coding-conversations');
const localSnapshot = createLocalConversationSnapshot(project.id, conversation);
@@ -461,7 +451,7 @@ describe('CodingChatPanel first Conversation', () => {
await waitFor(() => expect(
codingConversationStore.getState().entriesByConversationId[conversation.id]?.reducer.snapshot?.run.status,
).toBe('running'));
expect(screen.getByText('处理中')).toBeInTheDocument();
expect(screen.getByLabelText('对话正在运行')).toBeInTheDocument();
const snapshotCallsBeforeSleep = conversationApi.snapshot.mock.calls.length;
conversationApi.snapshot.mockResolvedValue(terminalSnapshot);
@@ -495,50 +485,15 @@ describe('CodingChatPanel first Conversation', () => {
}
});
it('does not let a slow first-Conversation completion steal selection after an Agent switch', async () => {
const slowCreate = deferred<CodingConversationMetadata>();
projectApi.list.mockResolvedValue({ projects: [project], activeProjectId: project.id });
projectApi.config.mockResolvedValue({ project, config: configForAgents([agent, reviewer]) });
projectApi.conversations.mockResolvedValue([reviewerConversation]);
projectApi.create.mockReturnValue(slowCreate.promise);
conversationApi.events.mockResolvedValue(new FakeEventSource() as unknown as EventSource);
conversationApi.submit.mockRejectedValue(new Error('Provider is intentionally not used'));
conversationApi.recover.mockResolvedValue(undefined);
const { CodingChatPanel } = await import('@/pages/Chat/CodingChatPanel');
const { createLocalConversationSnapshot } = await import('@/pages/Chat/coding-chat-snapshot');
const { codingConversationStore } = await import('@/stores/coding-conversations');
conversationApi.snapshot.mockImplementation(async (conversationId: string) => (
createLocalConversationSnapshot(
project.id,
conversationId === reviewerConversation.id ? reviewerConversation : conversation,
)
));
render(<CodingChatPanel />);
await screen.findByRole('textbox');
await waitFor(() => expect(projectApi.create).toHaveBeenCalledWith({
projectId: project.id,
agentId: agent.id,
title: '新对话',
}));
fireEvent.click(screen.getByRole('button', { name: /Reviewer/ }));
await waitFor(() => expect(codingConversationStore.getState().selectedConversationId)
.toBe(reviewerConversation.id));
await act(async () => {
slowCreate.resolve(conversation);
await slowCreate.promise;
});
expect(codingConversationStore.getState().selectedConversationId).toBe(reviewerConversation.id);
});
it('renders Conversations as children of only the selected Agent', async () => {
it('lists all project conversations without Agent navigation', async () => {
projectApi.list.mockResolvedValue({ projects: [project], activeProjectId: project.id });
projectApi.config.mockResolvedValue({ project, config: configForAgents([agent, reviewer]) });
projectApi.conversations.mockResolvedValue([conversation, reviewerConversation]);
conversationApi.events.mockResolvedValue(new FakeEventSource() as unknown as EventSource);
conversationApi.recover.mockResolvedValue(undefined);
const { CodingChatPanel } = await import('@/pages/Chat/CodingChatPanel');
const { CodingChatPanel: CoreChatPanel } = await import('@/pages/Chat/CodingChatPanel');
const { CodingProjectConversations } = await import('@/pages/Chat/CodingProjectConversations');
const CodingChatPanel = (props: import('@/pages/Chat/CodingChatPanel').CodingChatPanelProps) => <><CodingProjectConversations projectId={project.id} /><CoreChatPanel {...props} /></>;
const { createLocalConversationSnapshot } = await import('@/pages/Chat/coding-chat-snapshot');
conversationApi.snapshot.mockImplementation(async (conversationId: string) => (
createLocalConversationSnapshot(
@@ -548,22 +503,12 @@ describe('CodingChatPanel first Conversation', () => {
));
render(<CodingChatPanel />);
const builderButton = await screen.findByRole('button', { name: agent.name });
const reviewerButton = screen.getByRole('button', { name: reviewer.name });
expect(builderButton).toHaveAttribute('aria-expanded', 'true');
expect(reviewerButton).toHaveAttribute('aria-expanded', 'false');
const builderConversations = screen.getByRole('group', { name: `${agent.name} 的对话` });
expect(within(builderConversations).getByRole('button', { name: conversation.title })).toBeVisible();
expect(screen.queryByText(reviewerConversation.title)).not.toBeInTheDocument();
fireEvent.click(reviewerButton);
await waitFor(() => expect(reviewerButton).toHaveAttribute('aria-expanded', 'true'));
expect(builderButton).toHaveAttribute('aria-expanded', 'false');
const reviewerConversations = screen.getByRole('group', { name: `${reviewer.name} 的对话` });
expect(within(reviewerConversations)
.getByRole('button', { name: reviewerConversation.title })).toBeVisible();
expect(screen.queryByText(conversation.title)).not.toBeInTheDocument();
const list = await screen.findByRole('group', {name:'项目会话'});
expect(within(list).getByRole('button',{name:conversation.title})).toBeVisible();
expect(within(list).getByRole('button',{name:reviewerConversation.title})).toBeVisible();
expect(screen.queryByRole('button',{name:agent.name})).not.toBeInTheDocument();
fireEvent.click(within(list).getByRole('button',{name:reviewerConversation.title}));
await waitFor(()=>expect(within(list).getByRole('button',{name:reviewerConversation.title})).toHaveAttribute('aria-current','page'));
});
it('does not let a pending message fork steal selection after a Conversation switch', async () => {
@@ -574,7 +519,9 @@ describe('CodingChatPanel first Conversation', () => {
conversationApi.events.mockResolvedValue(new FakeEventSource() as unknown as EventSource);
conversationApi.recover.mockResolvedValue(undefined);
conversationApi.fork.mockReturnValue(forkFlight.promise);
const { CodingChatPanel } = await import('@/pages/Chat/CodingChatPanel');
const { CodingChatPanel: CoreChatPanel } = await import('@/pages/Chat/CodingChatPanel');
const { CodingProjectConversations } = await import('@/pages/Chat/CodingProjectConversations');
const CodingChatPanel = (props: import('@/pages/Chat/CodingChatPanel').CodingChatPanelProps) => <><CodingProjectConversations projectId={project.id} /><CoreChatPanel {...props} /></>;
const { createLocalConversationSnapshot } = await import('@/pages/Chat/coding-chat-snapshot');
const { codingConversationStore } = await import('@/stores/coding-conversations');
conversationApi.snapshot.mockImplementation(async (conversationId: string) => (
@@ -590,7 +537,7 @@ describe('CodingChatPanel first Conversation', () => {
fireEvent.click(screen.getByRole('button', { name: '从这里创建新对话分支' }));
await waitFor(() => expect(conversationApi.fork)
.toHaveBeenCalledWith(conversation.id, `entry-${conversation.id}`));
fireEvent.click(screen.getByRole('button', { name: /Reviewer/ }));
fireEvent.click(screen.getByRole('button', { name: reviewerConversation.title }));
await waitFor(() => expect(codingConversationStore.getState().selectedConversationId)
.toBe(reviewerConversation.id));
expect(screen.getByRole('button', { name: '从这里创建新对话分支' })).toBeEnabled();
@@ -622,7 +569,9 @@ describe('CodingChatPanel first Conversation', () => {
conversationApi.events.mockResolvedValue(new FakeEventSource() as unknown as EventSource);
conversationApi.recover.mockResolvedValue(undefined);
conversationApi.fork.mockReturnValue(forkFlight.promise);
const { CodingChatPanel } = await import('@/pages/Chat/CodingChatPanel');
const { CodingChatPanel: CoreChatPanel } = await import('@/pages/Chat/CodingChatPanel');
const { CodingProjectConversations } = await import('@/pages/Chat/CodingProjectConversations');
const CodingChatPanel = (props: import('@/pages/Chat/CodingChatPanel').CodingChatPanelProps) => <><CodingProjectConversations projectId={project.id} /><CoreChatPanel {...props} /></>;
const { createLocalConversationSnapshot } = await import('@/pages/Chat/coding-chat-snapshot');
const { codingWorkspaceStore } = await import('@/stores/coding-workspace');
const { codingConversationStore } = await import('@/stores/coding-conversations');
@@ -664,7 +613,9 @@ describe('CodingChatPanel first Conversation', () => {
projectApi.conversations.mockResolvedValue([conversation, reviewerConversation]);
conversationApi.events.mockResolvedValue(new FakeEventSource() as unknown as EventSource);
conversationApi.recover.mockResolvedValue(undefined);
const { CodingChatPanel } = await import('@/pages/Chat/CodingChatPanel');
const { CodingChatPanel: CoreChatPanel } = await import('@/pages/Chat/CodingChatPanel');
const { CodingProjectConversations } = await import('@/pages/Chat/CodingProjectConversations');
const CodingChatPanel = (props: import('@/pages/Chat/CodingChatPanel').CodingChatPanelProps) => <><CodingProjectConversations projectId={project.id} /><CoreChatPanel {...props} /></>;
const { createLocalConversationSnapshot } = await import('@/pages/Chat/coding-chat-snapshot');
const { codingWorkspaceStore } = await import('@/stores/coding-workspace');
const { codingConversationStore } = await import('@/stores/coding-conversations');
@@ -686,7 +637,7 @@ describe('CodingChatPanel first Conversation', () => {
});
expect(await screen.findByText('metadata rejected')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /Reviewer/ }));
fireEvent.click(screen.getByRole('button', { name: reviewerConversation.title }));
await waitFor(() => expect(codingConversationStore.getState().selectedConversationId)
.toBe(reviewerConversation.id));
expect(screen.queryByText('metadata rejected')).not.toBeInTheDocument();
@@ -698,7 +649,9 @@ describe('CodingChatPanel first Conversation', () => {
projectApi.conversations.mockResolvedValue([conversation, reviewerConversation]);
conversationApi.events.mockResolvedValue(new FakeEventSource() as unknown as EventSource);
conversationApi.recover.mockResolvedValue(undefined);
const { CodingChatPanel } = await import('@/pages/Chat/CodingChatPanel');
const { CodingChatPanel: CoreChatPanel } = await import('@/pages/Chat/CodingChatPanel');
const { CodingProjectConversations } = await import('@/pages/Chat/CodingProjectConversations');
const CodingChatPanel = (props: import('@/pages/Chat/CodingChatPanel').CodingChatPanelProps) => <><CodingProjectConversations projectId={project.id} /><CoreChatPanel {...props} /></>;
const { createLocalConversationSnapshot } = await import('@/pages/Chat/coding-chat-snapshot');
const { codingConversationStore } = await import('@/stores/coding-conversations');
conversationApi.snapshot.mockImplementation(async (conversationId: string) => (
@@ -720,7 +673,7 @@ describe('CodingChatPanel first Conversation', () => {
expect(screen.getAllByRole('button', { name: '项目设置' })).toHaveLength(1);
fireEvent.click(screen.getByRole('button', { name: '项目设置' }));
expect(onOpenProjectSettings).toHaveBeenCalledOnce();
expect(navigate).toHaveBeenCalledWith('/project-config');
});
it('remounts pending interaction state when switching Conversations', async () => {
@@ -731,7 +684,9 @@ describe('CodingChatPanel first Conversation', () => {
conversationApi.events.mockResolvedValue(new FakeEventSource() as unknown as EventSource);
conversationApi.recover.mockResolvedValue(undefined);
conversationApi.respond.mockReturnValue(respondFlight.promise);
const { CodingChatPanel } = await import('@/pages/Chat/CodingChatPanel');
const { CodingChatPanel: CoreChatPanel } = await import('@/pages/Chat/CodingChatPanel');
const { CodingProjectConversations } = await import('@/pages/Chat/CodingProjectConversations');
const CodingChatPanel = (props: import('@/pages/Chat/CodingChatPanel').CodingChatPanelProps) => <><CodingProjectConversations projectId={project.id} /><CoreChatPanel {...props} /></>;
const { createLocalConversationSnapshot } = await import('@/pages/Chat/coding-chat-snapshot');
const { codingConversationStore } = await import('@/stores/coding-conversations');
conversationApi.snapshot.mockImplementation(async (conversationId: string) => {
@@ -757,7 +712,7 @@ describe('CodingChatPanel first Conversation', () => {
interactionId: `interaction-${conversation.id}`,
confirmed: true,
}));
fireEvent.click(screen.getByRole('button', { name: /Reviewer/ }));
fireEvent.click(screen.getByRole('button', { name: reviewerConversation.title }));
await waitFor(() => expect(codingConversationStore.getState().selectedConversationId)
.toBe(reviewerConversation.id));
expect(await screen.findByText(`Confirm ${reviewerConversation.id}`)).toBeInTheDocument();
@@ -778,7 +733,9 @@ describe('CodingChatPanel first Conversation', () => {
conversationApi.events.mockResolvedValue(new FakeEventSource() as unknown as EventSource);
conversationApi.submit.mockReturnValueOnce(pendingSubmit.promise);
conversationApi.recover.mockResolvedValue(undefined);
const { CodingChatPanel } = await import('@/pages/Chat/CodingChatPanel');
const { CodingChatPanel: CoreChatPanel } = await import('@/pages/Chat/CodingChatPanel');
const { CodingProjectConversations } = await import('@/pages/Chat/CodingProjectConversations');
const CodingChatPanel = (props: import('@/pages/Chat/CodingChatPanel').CodingChatPanelProps) => <><CodingProjectConversations projectId={project.id} /><CoreChatPanel {...props} /></>;
const { createLocalConversationSnapshot } = await import('@/pages/Chat/coding-chat-snapshot');
const { codingConversationStore } = await import('@/stores/coding-conversations');
conversationApi.snapshot.mockImplementation(async (conversationId: string) => (
@@ -802,7 +759,7 @@ describe('CodingChatPanel first Conversation', () => {
fireEvent.click(screen.getByRole('button', { name: '发送' }));
await waitFor(() => expect(conversationApi.submit).toHaveBeenCalledOnce());
fireEvent.click(screen.getByRole('button', { name: /Reviewer/ }));
fireEvent.click(screen.getByRole('button', { name: reviewerConversation.title }));
await waitFor(() => expect(screen.getByRole('textbox')).toHaveValue(''));
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'B prompt' } });
await waitFor(() => expect(screen.getByRole('button', { name: '发送' })).toBeEnabled());
@@ -835,7 +792,9 @@ describe('CodingChatPanel first Conversation', () => {
runId: 'run-retry',
mode: input.mode,
}));
const { CodingChatPanel } = await import('@/pages/Chat/CodingChatPanel');
const { CodingChatPanel: CoreChatPanel } = await import('@/pages/Chat/CodingChatPanel');
const { CodingProjectConversations } = await import('@/pages/Chat/CodingProjectConversations');
const CodingChatPanel = (props: import('@/pages/Chat/CodingChatPanel').CodingChatPanelProps) => <><CodingProjectConversations projectId={project.id} /><CoreChatPanel {...props} /></>;
const { createLocalConversationSnapshot } = await import('@/pages/Chat/coding-chat-snapshot');
const { codingConversationStore } = await import('@/stores/coding-conversations');
conversationApi.snapshot.mockResolvedValue(createLocalConversationSnapshot(project.id, conversation));
@@ -871,7 +830,9 @@ describe('CodingChatPanel first Conversation', () => {
undefined,
{ backendCode: 'CODING_REQUEST_UNCERTAIN' },
));
const { CodingChatPanel } = await import('@/pages/Chat/CodingChatPanel');
const { CodingChatPanel: CoreChatPanel } = await import('@/pages/Chat/CodingChatPanel');
const { CodingProjectConversations } = await import('@/pages/Chat/CodingProjectConversations');
const CodingChatPanel = (props: import('@/pages/Chat/CodingChatPanel').CodingChatPanelProps) => <><CodingProjectConversations projectId={project.id} /><CoreChatPanel {...props} /></>;
const { createLocalConversationSnapshot } = await import('@/pages/Chat/coding-chat-snapshot');
const { codingConversationStore } = await import('@/stores/coding-conversations');
conversationApi.snapshot.mockResolvedValue(createLocalConversationSnapshot(project.id, conversation));
@@ -949,7 +910,9 @@ describe('CodingChatPanel first Conversation', () => {
conversationApi.events.mockResolvedValue(new FakeEventSource() as unknown as EventSource);
conversationApi.recover.mockResolvedValue(undefined);
conversationApi.abort.mockResolvedValue(undefined);
const { CodingChatPanel } = await import('@/pages/Chat/CodingChatPanel');
const { CodingChatPanel: CoreChatPanel } = await import('@/pages/Chat/CodingChatPanel');
const { CodingProjectConversations } = await import('@/pages/Chat/CodingProjectConversations');
const CodingChatPanel = (props: import('@/pages/Chat/CodingChatPanel').CodingChatPanelProps) => <><CodingProjectConversations projectId={project.id} /><CoreChatPanel {...props} /></>;
const { createLocalConversationSnapshot } = await import('@/pages/Chat/coding-chat-snapshot');
const runningSnapshot: ConversationSnapshot = {
...createLocalConversationSnapshot(project.id, conversation),
@@ -1018,7 +981,9 @@ describe('CodingChatPanel first Conversation', () => {
byteLength: file.size,
}));
});
const { CodingChatPanel } = await import('@/pages/Chat/CodingChatPanel');
const { CodingChatPanel: CoreChatPanel } = await import('@/pages/Chat/CodingChatPanel');
const { CodingProjectConversations } = await import('@/pages/Chat/CodingProjectConversations');
const CodingChatPanel = (props: import('@/pages/Chat/CodingChatPanel').CodingChatPanelProps) => <><CodingProjectConversations projectId={project.id} /><CoreChatPanel {...props} /></>;
const { createLocalConversationSnapshot } = await import('@/pages/Chat/coding-chat-snapshot');
const { codingConversationStore } = await import('@/stores/coding-conversations');
conversationApi.snapshot.mockResolvedValue(createLocalConversationSnapshot(project.id, conversation));
@@ -1083,7 +1048,9 @@ describe('CodingChatPanel first Conversation', () => {
? Promise.reject(new Error('图片附件无效。'))
: upload.promise
));
const { CodingChatPanel } = await import('@/pages/Chat/CodingChatPanel');
const { CodingChatPanel: CoreChatPanel } = await import('@/pages/Chat/CodingChatPanel');
const { CodingProjectConversations } = await import('@/pages/Chat/CodingProjectConversations');
const CodingChatPanel = (props: import('@/pages/Chat/CodingChatPanel').CodingChatPanelProps) => <><CodingProjectConversations projectId={project.id} /><CoreChatPanel {...props} /></>;
const { createLocalConversationSnapshot } = await import('@/pages/Chat/coding-chat-snapshot');
const { codingConversationStore } = await import('@/stores/coding-conversations');
conversationApi.snapshot.mockResolvedValue(createLocalConversationSnapshot(project.id, conversation));
@@ -1131,7 +1098,9 @@ describe('CodingChatPanel first Conversation', () => {
mime: file.type,
byteLength: file.size,
}));
const { CodingChatPanel } = await import('@/pages/Chat/CodingChatPanel');
const { CodingChatPanel: CoreChatPanel } = await import('@/pages/Chat/CodingChatPanel');
const { CodingProjectConversations } = await import('@/pages/Chat/CodingProjectConversations');
const CodingChatPanel = (props: import('@/pages/Chat/CodingChatPanel').CodingChatPanelProps) => <><CodingProjectConversations projectId={project.id} /><CoreChatPanel {...props} /></>;
const { createLocalConversationSnapshot } = await import('@/pages/Chat/coding-chat-snapshot');
const { codingConversationStore } = await import('@/stores/coding-conversations');
conversationApi.snapshot.mockResolvedValue(createLocalConversationSnapshot(project.id, conversation));
@@ -1191,7 +1160,9 @@ describe('CodingChatPanel first Conversation', () => {
byteLength: file.size,
};
});
const { CodingChatPanel } = await import('@/pages/Chat/CodingChatPanel');
const { CodingChatPanel: CoreChatPanel } = await import('@/pages/Chat/CodingChatPanel');
const { CodingProjectConversations } = await import('@/pages/Chat/CodingProjectConversations');
const CodingChatPanel = (props: import('@/pages/Chat/CodingChatPanel').CodingChatPanelProps) => <><CodingProjectConversations projectId={project.id} /><CoreChatPanel {...props} /></>;
const { createLocalConversationSnapshot } = await import('@/pages/Chat/coding-chat-snapshot');
const { codingConversationStore } = await import('@/stores/coding-conversations');
conversationApi.snapshot.mockResolvedValue(createLocalConversationSnapshot(project.id, conversation));
@@ -1240,7 +1211,9 @@ describe('CodingChatPanel first Conversation', () => {
runId: 'run-1',
mode: input.mode,
}));
const { CodingChatPanel } = await import('@/pages/Chat/CodingChatPanel');
const { CodingChatPanel: CoreChatPanel } = await import('@/pages/Chat/CodingChatPanel');
const { CodingProjectConversations } = await import('@/pages/Chat/CodingProjectConversations');
const CodingChatPanel = (props: import('@/pages/Chat/CodingChatPanel').CodingChatPanelProps) => <><CodingProjectConversations projectId={project.id} /><CoreChatPanel {...props} /></>;
const { createLocalConversationSnapshot } = await import('@/pages/Chat/coding-chat-snapshot');
const { codingConversationStore } = await import('@/stores/coding-conversations');
const runningSnapshot: ConversationSnapshot = {

View File

@@ -252,26 +252,12 @@ describe('PI-130 feature-complete Coding UI', () => {
it('uses the custom title bar for the full-height Conversation columns', async () => {
const originalPlatform = window.electron.platform;
const { useSettingsStore } = await import('@/stores/settings');
const { CodingConversationSidebar } = await import('@/pages/Chat/CodingConversationSidebar');
const { CodingConversationHeader } = await import('@/pages/Chat/CodingConversationHeader');
window.electron.platform = 'darwin';
useSettingsStore.setState({ sidebarCollapsed: true });
render(
<>
<CodingConversationSidebar
projectName="Layout project"
agents={[]}
conversations={[]}
conversationSummaries={{}}
selectedAgentId={null}
selectedConversationId={null}
creatingAgentIds={{}}
onSelectAgent={vi.fn()}
onSelectConversation={vi.fn()}
onCreateConversation={vi.fn()}
onOpenProjectSettings={vi.fn()}
/>
<CodingConversationHeader
conversation={null}
snapshot={null}
@@ -282,13 +268,9 @@ describe('PI-130 feature-complete Coding UI', () => {
</>,
);
expect(screen.getByTestId('coding-conversation-sidebar-titlebar')).toHaveClass('fixed', 'top-0', 'h-10');
expect(screen.getByTestId('coding-conversation-sidebar-titlebar')).toHaveStyle({ left: '0px' });
expect(screen.getByTestId('coding-conversation-header-titlebar')).toHaveClass('fixed', 'top-0', 'h-10');
expect(screen.getByTestId('coding-conversation-header-titlebar')).toHaveStyle({ left: '256px' });
expect(screen.queryByText('Pi 本地对话')).not.toBeInTheDocument();
expect(within(screen.getByTestId('coding-conversation-sidebar'))
.queryByTestId('coding-conversation-sidebar-header')).not.toBeInTheDocument();
expect(screen.getByTestId('coding-conversation-header-titlebar')).toHaveStyle({ left: '132px' });
expect(screen.queryByTestId('coding-conversation-sidebar')).not.toBeInTheDocument();
window.electron.platform = originalPlatform;
useSettingsStore.setState({ sidebarCollapsed: false });

View File

@@ -77,7 +77,7 @@ describe('coding project durable identity', () => {
const reopened = await projects.createProject(input);
expect(reopened.config.projectId).toBe(PROJECT_ID);
expect(reopened.config.projectType).toBe('custom');
expect(reopened.config.agents).toEqual([agent]);
expect(reopened.config.agents).toEqual([...created.config.agents, agent]);
expect(reopened.knowledgeFiles).toEqual(['notes.md']);
expect(await projects.getActiveProject()).toEqual(reopened.project);
expect(await projects.listProjects()).toEqual([reopened.project]);

View File

@@ -139,7 +139,7 @@ describe('coding project schema v2', () => {
const conversationDurationMs = performance.now() - conversationStartedAt;
expect(project.id).toBe('project-stable-id');
expect(config).toMatchObject({ schemaVersion: 2, agents: [] });
expect(config).toMatchObject({ schemaVersion: 2, initialized: true, defaultAgentId: 'coding-default', agents: [{ id: 'coding-default', builtIn: true, modelResolution: 'required' }] });
expect(agent).toMatchObject({
id: 'implementer',
name: 'Implementation Agent',

View File

@@ -0,0 +1,145 @@
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { TeacherChatPanel } from '@/pages/Chat/TeacherChatPanel';
import type { TeacherDefinition, TeacherTopic } from '../../shared/coding-teacher';
const api = vi.hoisted(() => ({
config: vi.fn(),
preview: vi.fn(),
list: vi.fn(),
create: vi.fn(),
read: vi.fn(),
send: vi.fn(),
cancel: vi.fn(),
save: vi.fn(),
events: vi.fn(),
}));
vi.mock('@/lib/coding-teacher', () => ({
teacherApi: api,
teacherTopicsPath: (p: string, s: string) => p + '/' + s,
}));
const definition: TeacherDefinition = {
schema_version: 1,
teacher_id: 'coding-teacher',
name: '老师',
description: '',
avatar_id: 'avatar-01',
welcome_message: '欢迎',
suggested_questions: [],
system_prompt: '教学',
skills: [],
model: { model_id: 'qwen', reasoning_choice: { mode: 'default' } },
limits: { max_input_tokens: 8000, max_output_tokens: 1500 },
};
function topic(id: string): TeacherTopic {
return {
schemaVersion: 1,
revision: 1,
id,
accountId: 'user',
projectId: 'p',
sourceConversationId: 'c',
definition,
version: 1,
createdAt: '2026-09-22',
updatedAt: '2026-09-22',
requests: [],
};
}
const first = topic('first'),
second = topic('second');
let streams: Map<string, EventTarget & { close: ReturnType<typeof vi.fn> }>;
beforeEach(() => {
vi.resetAllMocks();
streams = new Map();
api.config.mockResolvedValue({ enabled: true, published_version: 1, revision: 1, definition });
api.list.mockResolvedValue({
items: [
{ id: 'first', title: '第一个' },
{ id: 'second', title: '第二个' },
],
lastSelectedTopicId: 'first',
});
api.read.mockImplementation(async (_base, id) =>
id === 'first' ? structuredClone(first) : structuredClone(second)
);
api.create.mockResolvedValue(first);
api.events.mockImplementation(async (_base, id) => {
const stream = Object.assign(new EventTarget(), { close: vi.fn() });
streams.set(id, stream);
return stream;
});
});
async function ready() {
await waitFor(() =>
expect(screen.getByRole('combobox', { name: '老师话题' })).toHaveValue('first')
);
await waitFor(() => expect(streams.has('first')).toBe(true));
}
describe('teacher side chat', () => {
it('retains failed input and retries with the same request id, then appends a reply only on click', async () => {
const bringBack = vi.fn();
api.send
.mockRejectedValueOnce(new Error('网络暂不可用'))
.mockImplementationOnce(async (_base, _id, input) => ({
...first,
revision: 3,
requests: [
{
id: input.requestId,
text: input.text,
references: [],
createdAt: 'now',
sourceCursor: { workerGeneration: 1, seq: 1 },
sourceCapturedAt: 'now',
includedSourceMessageIds: [],
omittedMessages: 0,
status: 'completed',
response: '教学回答',
},
],
}));
render(<TeacherChatPanel projectId="p" sourceId="c" onBringBack={bringBack} />);
await ready();
fireEvent.change(screen.getByLabelText('向老师提问'), { target: { value: '解释代码' } });
fireEvent.click(screen.getByRole('button', { name: '提问', exact: true }));
await screen.findByText('网络暂不可用');
expect(screen.getByLabelText('向老师提问')).toHaveValue('解释代码');
fireEvent.click(screen.getByRole('button', { name: '提问', exact: true }));
await screen.findByText('教学回答');
expect(api.send.mock.calls[0][2].requestId).toBe(api.send.mock.calls[1][2].requestId);
expect(bringBack).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('button', { name: '带回主会话草稿' }));
expect(bringBack).toHaveBeenCalledWith('教学回答');
});
it('ignores an old topic stream after switching and does not cancel when closed', async () => {
const closed = vi.fn();
const view = render(<TeacherChatPanel projectId="p" sourceId="c" onClose={closed} />);
await ready();
const old = streams.get('first')!;
fireEvent.change(screen.getByRole('combobox', { name: '老师话题' }), {
target: { value: 'second' },
});
await waitFor(() =>
expect(screen.getByRole('combobox', { name: '老师话题' })).toHaveValue('second')
);
act(() =>
old.dispatchEvent(
new MessageEvent('snapshot', { data: JSON.stringify({ ...first, revision: 999 }) })
)
);
expect(screen.getByRole('combobox', { name: '老师话题' })).toHaveValue('second');
fireEvent.click(screen.getByRole('button', { name: '关闭老师' }));
expect(closed).toHaveBeenCalledOnce();
view.unmount();
expect(api.cancel).not.toHaveBeenCalled();
expect(old.close).toHaveBeenCalled();
});
it('shows disabled history while blocking a new question', async () => {
api.config.mockResolvedValue({ enabled: false, definition });
render(<TeacherChatPanel projectId="p" sourceId="c" />);
await ready();
fireEvent.change(screen.getByLabelText('向老师提问'), { target: { value: '问题' } });
expect(screen.getByRole('button', { name: '提问', exact: true })).toBeDisabled();
expect(api.read).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,437 @@
// @vitest-environment node
import { mkdtemp, readFile, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { CodingTeacherService } from '../../electron/coding-teacher/service';
import { TeacherTopicStore } from '../../electron/coding-teacher/store';
import { compileTeacherContext, sourceContext } from '../../electron/coding-teacher/context';
import { streamTeacherReply } from '../../electron/coding-teacher/model-runner';
import { TeacherError } from '../../electron/coding-teacher/config-client';
import {
createCodingProjectStore,
createMemoryCodingProjectStorage,
} from '../../electron/coding-projects/project-store';
import { CodingProjectService } from '../../electron/coding-projects/project-service';
import {
ensureDefaultCodingAgent,
createCodingProjectConfigV2,
} from '../../electron/coding-projects/project-config';
import { InMemoryConversationRuntime } from '../../electron/coding-runtime/in-memory-conversation-runtime';
import type { TeacherDefinition, TeacherSourceContext } from '../../shared/coding-teacher';
import type { ConversationSnapshot } from '../../shared/coding-conversation-contracts';
import { parseNianCodeDeepLinkUrl } from '../../electron/main/app-deep-link';
const definition: TeacherDefinition = {
schema_version: 1,
teacher_id: 'coding-teacher',
name: '编程老师',
description: '',
avatar_id: 'avatar-01',
welcome_message: '一起学编程',
suggested_questions: ['为什么?'],
system_prompt: '通过问题引导思考。',
skills: [
{
id: 'explain',
name: '讲解',
description: '',
enabled: true,
instructions_markdown: '使用具体的小例子。',
},
],
model: { model_id: 'qwen', reasoning_choice: { mode: 'default' } },
limits: { max_input_tokens: 8000, max_output_tokens: 1500 },
};
const context: TeacherSourceContext = {
messages: [{ id: 'source-user', role: 'user', text: '创建计数器' }],
cursor: { workerGeneration: 1, seq: 3 },
capturedAt: '2026-09-22T00:00:00Z',
};
const roots: string[] = [];
const services: CodingTeacherService[] = [];
afterEach(async () => {
await Promise.all(services.splice(0).map((service) => service.dispose()));
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
});
async function fixture() {
const root = await mkdtemp(path.join(tmpdir(), 'coding-teacher-'));
roots.push(root);
const projects = new CodingProjectService(
createCodingProjectStore(createMemoryCodingProjectStorage())
);
const created = await projects.createProject({
projectPath: path.join(root, 'project'),
identity: { kind: 'create' },
});
const source = await projects
.conversationStore(created.project.path)
.create({
agentId: created.config.defaultAgentId!,
title: '源码会话',
model: null,
modelResolution: 'required',
});
const scope = { projectId: created.project.id, sourceId: source.id };
let enabled = true,
version = 1,
accountCurrent = true;
let finish: () => void = () => undefined;
const run = vi.fn(async (_messages, signal: AbortSignal, onText: (text: string) => void) => {
onText('计数器保存一个数字。');
await new Promise<void>((resolve, reject) => {
finish = resolve;
signal.addEventListener('abort', () => reject(new Error('aborted')), { once: true });
});
return { inputTokens: 20, outputTokens: 10 };
});
const account = {
id: '11111111-1111-4111-8111-111111111111',
binding: { accountKey: 'test', epoch: 1 },
};
const service = new CodingTeacherService({
projects,
runtime: new InMemoryConversationRuntime(),
userDataDir: root,
account: async () => account,
assertAccount: () => {
if (!accountCurrent) throw new TeacherError(401, 'teacher_account_changed', '账号变化');
},
availability: async () => ({ enabled, published_version: version, revision: version }),
version: async (_account, v) => ({
version: v,
payload: { ...definition, name: '老师 v' + v },
}),
preview: async (_account, revision) => {
if (revision !== 2) throw new TeacherError(409, 'teacher_draft_changed', '草稿变化');
return { draft_revision: revision, payload: definition };
},
readSource: async () => structuredClone(context),
prepareModel: async () => ({ inputLimit: 8000, run }),
});
services.push(service);
return {
root,
projects,
created,
scope,
service,
run,
finish: () => finish(),
disable: () => {
enabled = false;
},
nextVersion: () => {
version++;
},
switchAccount: () => {
accountCurrent = false;
},
};
}
describe('cloud coding teacher', () => {
it('persists a fixed version, deduplicates requests, and does not change coding metadata', async () => {
const f = await fixture();
const before = await readFile(
path.join(f.created.project.path, '.makelore/conversations.json'),
'utf8'
);
const topic = await f.service.create(f.scope);
f.nextVersion();
const next = await f.service.create(f.scope);
expect(next.version).toBe(2);
expect(topic.version).toBe(1);
const input = { requestId: '22222222-2222-4222-8222-222222222222', text: '解释一下' };
await f.service.send(f.scope, topic.id, input);
await f.service.send(f.scope, topic.id, input);
expect(f.run).toHaveBeenCalledOnce();
await expect(
f.service.send(f.scope, topic.id, { ...input, text: '不同问题' })
).rejects.toMatchObject({ code: 'teacher_request_conflict' });
f.finish();
await vi.waitFor(async () =>
expect((await f.service.read(f.scope, topic.id)).requests[0].status).toBe('completed')
);
expect(
await readFile(path.join(f.created.project.path, '.makelore/conversations.json'), 'utf8')
).toBe(before);
expect((await f.service.list(f.scope)).items).toHaveLength(2);
});
it('honors disable for old topics while keeping readable history', async () => {
const f = await fixture(),
topic = await f.service.create(f.scope);
f.disable();
await expect(
f.service.send(f.scope, topic.id, {
requestId: '22222222-2222-4222-8222-222222222222',
text: '问题',
})
).rejects.toMatchObject({ code: 'teacher_disabled' });
expect((await f.service.read(f.scope, topic.id)).version).toBe(1);
expect(f.run).not.toHaveBeenCalled();
});
it('cancels partial replies and rejects another concurrent question', async () => {
const f = await fixture(),
topic = await f.service.create(f.scope),
id = '22222222-2222-4222-8222-222222222222';
await f.service.send(f.scope, topic.id, { requestId: id, text: '问题' });
await expect(
f.service.send(f.scope, topic.id, {
requestId: '33333333-3333-4333-8333-333333333333',
text: '另一个问题',
})
).rejects.toMatchObject({ code: 'teacher_topic_busy' });
await f.service.cancel(f.scope, topic.id, id);
await vi.waitFor(async () =>
expect((await f.service.read(f.scope, topic.id)).requests[0]).toMatchObject({
status: 'cancelled',
response: '计数器保存一个数字。',
})
);
});
it('retains interrupted requests on restart without submitting again', async () => {
const f = await fixture(),
topic = await f.service.create(f.scope),
id = '22222222-2222-4222-8222-222222222222';
await f.service.send(f.scope, topic.id, { requestId: id, text: '问题' });
const store = new TeacherTopicStore(
path.join(
f.created.project.path,
'.makelore/teacher-conversations',
topic.accountId,
f.scope.sourceId
)
);
expect((await store.read(topic.id)).requests[0].status).toBe('interrupted');
expect(f.run).toHaveBeenCalledOnce();
await f.service.cancel(f.scope, topic.id, id);
});
it('checks exact preview revision and never reads project context for preview', async () => {
const f = await fixture(),
scope = { projectId: 'preview', sourceId: 'preview' };
await expect(f.service.create(scope, 1, 'sample')).rejects.toMatchObject({
code: 'teacher_draft_changed',
});
const topic = await f.service.create(scope, 2, '示例项目的计数器');
await f.service.send(scope, topic.id, {
requestId: '22222222-2222-4222-8222-222222222222',
text: '怎么改进',
});
expect(JSON.stringify(f.run.mock.calls[0][0])).toContain('示例项目的计数器');
expect(JSON.stringify(f.run.mock.calls[0][0])).not.toContain('创建计数器');
f.finish();
});
it('rejects foreign message references before submitting a model request', async () => {
const f = await fixture(),
topic = await f.service.create(f.scope);
await expect(
f.service.send(f.scope, topic.id, {
requestId: '22222222-2222-4222-8222-222222222222',
text: '解释',
references: [{ kind: 'message', messageId: 'another-source', text: '别的项目' }],
})
).rejects.toMatchObject({ code: 'teacher_reference_invalid' });
expect(f.run).not.toHaveBeenCalled();
});
it('cascades source deletion after cancelling its reply, without recreating files', async () => {
const f = await fixture(),
topic = await f.service.create(f.scope);
await f.service.send(f.scope, topic.id, {
requestId: '22222222-2222-4222-8222-222222222222',
text: '问题',
});
await f.service.removeSource(f.scope.projectId, f.scope.sourceId);
await expect(f.service.read(f.scope, topic.id)).rejects.toMatchObject({
code: 'teacher_source_not_found',
});
await expect(
readFile(
path.join(
f.created.project.path,
'.makelore/teacher-conversations',
topic.accountId,
f.scope.sourceId,
topic.id + '.json'
)
)
).rejects.toMatchObject({ code: 'ENOENT' });
});
it('fails account changes before dispatch', async () => {
const f = await fixture(),
topic = await f.service.create(f.scope);
f.switchAccount();
await f.service.send(f.scope, topic.id, {
requestId: '22222222-2222-4222-8222-222222222222',
text: '问题',
});
await vi.waitFor(async () =>
expect((await f.service.read(f.scope, topic.id)).requests[0].status).toBe('failed')
);
expect(f.run).not.toHaveBeenCalled();
});
});
describe('teacher context and wire contract', () => {
it('takes only complete user/assistant text and preserves the read cursor', () => {
const snapshot = {
nodes: [
{
kind: 'message',
id: 'u',
role: 'user',
status: 'complete',
blocks: [{ kind: 'text', status: 'complete', text: '用户问题' }],
},
{
kind: 'message',
id: 'a',
role: 'assistant',
status: 'complete',
blocks: [
{ kind: 'thinking', status: 'complete', text: 'private' },
{ kind: 'text', status: 'complete', text: '完整回答' },
],
},
{
kind: 'message',
id: 'live',
role: 'assistant',
status: 'streaming',
blocks: [{ kind: 'text', status: 'streaming', text: '未完成' }],
},
{ kind: 'tool', id: 'tool', args: { password: 'secret' } },
],
cursor: { workerGeneration: 2, seq: 10 },
} as ConversationSnapshot;
const selected = sourceContext(snapshot);
expect(selected.messages.map((message) => message.text)).toEqual(['用户问题', '完整回答']);
expect(selected.cursor).toEqual(snapshot.cursor);
});
it('trims old source messages but retains instructions, Skill, explicit quote and question', () => {
const compiled = compileTeacherContext(
definition,
{
...context,
messages: [{ id: 'old', role: 'user', text: 'old'.repeat(4000) }, ...context.messages],
},
[],
'为什么这样?',
[{ kind: 'code', text: 'count += 1' }],
1200
);
const text = JSON.stringify(compiled.messages);
expect(text).toContain('通过问题引导思考');
expect(text).toContain('使用具体的小例子');
expect(text).toContain('count += 1');
expect(compiled.omittedMessages).toBe(1);
expect(() => compileTeacherContext(definition, context, [], 'x'.repeat(9000), [])).toThrow(
'超过上下文预算'
);
});
it('sends no tools, ignores reasoning deltas, and requires a terminal stream', async () => {
const text: string[] = [];
const fake = vi.fn(
async () =>
new Response(
'data: {"choices":[{"delta":{"reasoning_content":"hidden","content":"答案"}}]}\n\ndata: {"choices":[{"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":10,"completion_tokens":3}}\n\ndata: [DONE]\n\n'
)
);
const usage = await streamTeacherReply(
{ base_url: 'https://gateway.test/v1', api_key: 'private' },
'model',
{},
1500,
[{ role: 'user', content: '问题' }],
new AbortController().signal,
(delta) => text.push(delta),
fake
);
expect(text).toEqual(['答案']);
expect(usage).toEqual({ inputTokens: 10, outputTokens: 3 });
const input = fake.mock.calls[0] as unknown as [string, RequestInit];
const body = JSON.parse(input[1].body as string);
expect(body).not.toHaveProperty('tools');
expect(input[0]).toBe('https://gateway.test/v1/chat/completions');
await expect(
streamTeacherReply(
{ base_url: 'https://gateway.test/v1', api_key: 'private' },
'model',
{},
1500,
[],
new AbortController().signal,
() => undefined,
async () => new Response('data: {"choices":[{"delta":{"content":"半句"}}]}\n\n')
)
).rejects.toMatchObject({ code: 'teacher_stream_interrupted' });
});
it('accepts only the exact credential-free preview link', () => {
expect(
parseNianCodeDeepLinkUrl('niancode://coding-teacher/preview?draft_revision=2')
).toMatchObject({ type: 'teacher-preview', draftRevision: 2 });
expect(
parseNianCodeDeepLinkUrl('niancode://coding-teacher/preview?draft_revision=2&token=x')
).toBeNull();
expect(
parseNianCodeDeepLinkUrl('niancode://coding-teacher/preview?draft_revision=0')
).toBeNull();
});
it('creates one default and preserves all legacy identities without reviving disabled Agents', () => {
const base = createCodingProjectConfigV2();
expect(base.agents).toHaveLength(1);
expect(ensureDefaultCodingAgent(base)).toBe(base);
const legacy = {
...base,
defaultAgentId: undefined,
agents: [
{ ...base.agents[0], id: 'old', enabled: false },
{ ...base.agents[0], id: 'existing', pinned: true },
],
};
const normalized = ensureDefaultCodingAgent(legacy);
expect(normalized.defaultAgentId).toBe('existing');
expect(normalized.agents).toEqual(legacy.agents);
const disabled = ensureDefaultCodingAgent({ ...legacy, agents: [legacy.agents[0]] });
expect(disabled.agents[0].enabled).toBe(false);
expect(disabled.agents).toHaveLength(2);
});
});
import {mkdir, writeFile} from 'node:fs/promises';
import {readCodingConversationHistory} from '../../electron/coding-projects/conversation-history';
import {getPiManagedPaths} from '../../electron/coding-runtime/pi/resource-loader';
it('reads only the durable active branch without starting a worker', async()=>{
const f=await fixture();
const metadata=(await f.projects.conversationStore(f.created.project.path).get(f.scope.sourceId))!;
const folder=path.join(getPiManagedPaths(f.root).sessionsDir,f.scope.projectId);await mkdir(folder,{recursive:true});
const entries=[
{type:'session',id:'session'},
{type:'message',id:'root',parentId:null,message:{role:'user',content:'开始学习'}},
{type:'message',id:'abandoned',parentId:'root',message:{role:'user',content:'旧分支内容'}},
{type:'message',id:'active',parentId:'root',message:{role:'user',content:'当前分支内容'}},
];
await writeFile(path.join(folder,'session-test.jsonl'),entries.map(entry=>JSON.stringify(entry)).join('\n'));
const snapshot=await readCodingConversationHistory(f.root,f.scope.projectId,{...metadata,sessionKey:'session-test'});
expect(sourceContext(snapshot).messages.map(message=>message.text)).toEqual(['开始学习','当前分支内容']);
expect(snapshot.worker.status).toBe('stopped');
});
import {createServer} from 'node:http';
import {handleCodingTeacherRoutes} from '../../electron/api/routes/coding-teacher';
import type {HostApiContext} from '../../electron/api/context';
it('serves topic acceptance and SSE snapshots without cancelling on stream close',async()=>{
const f=await fixture();
const server=createServer((req,res)=>{void handleCodingTeacherRoutes(req,res,new URL(req.url!,'http://localhost'),{codingProducts:{teacher:f.service}} as HostApiContext);});
await new Promise<void>(resolve=>server.listen(0,'127.0.0.1',resolve));
const address=server.address();if(!address||typeof address==='string')throw new Error('no address');
const origin='http://127.0.0.1:'+address.port;
const base=origin+'/api/coding/projects/'+f.scope.projectId+'/conversations/'+f.scope.sourceId+'/teacher-topics';
try {
const created=await fetch(base,{method:'POST',headers:{'Content-Type':'application/json'},body:'{}'});expect(created.status).toBe(201);
const topic=await created.json();const requestId='22222222-2222-4222-8222-222222222222';
const accepted=await fetch(base+'/'+topic.id+'/messages',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({requestId,text:'讲解一下'})});expect(accepted.status).toBe(202);
const stream=await fetch(base+'/'+topic.id+'/events');expect(stream.headers.get('content-type')).toContain('text/event-stream');
const reader=stream.body!.getReader();const chunk=await reader.read();expect(new TextDecoder().decode(chunk.value)).toContain('event: snapshot');await reader.cancel();
expect((await f.service.read(f.scope,topic.id)).requests[0].status).toBe('running');
const cancelled=await fetch(base+'/'+topic.id+'/requests/'+requestId+'/cancel',{method:'POST'});expect(cancelled.status).toBe(200);
await vi.waitFor(async()=>expect((await f.service.read(f.scope,topic.id)).requests[0].status).toBe('cancelled'));
expect((await fetch(origin+'/api/coding/teacher/config',{method:'POST'})).status).toBe(405);
} finally {server.closeAllConnections();await new Promise<void>((resolve,reject)=>server.close(error=>error?reject(error):resolve()));}
});

View File

@@ -306,3 +306,21 @@ describe('coding workspace store', () => {
expect(store.getState().conversationErrorsByProjectId[secondProject.id]).toBeUndefined();
});
});
it('keeps the newer project visible when an earlier activation finishes late', async () => {
const second = {...project,id:'project-2',name:'Second'};
const delayed = deferred<CodingProjectSummary>();
let active = project.id;
const store = createCodingWorkspaceStore({
listProjects: async () => ({projects:[project,second],activeProjectId:active}),
getConfig: async (id) => ({project:id===project.id?project:second,config:config([agent('default')])}),
listConversations: async id => [conversation(id+'-chat','default')],
setActiveProject: async id => {if(id===project.id)return delayed.promise;active=id;return second;},
});
await store.getState().load();
const old = store.getState().setActiveProject(project.id);
const latest = store.getState().setActiveProject(second.id);
delayed.resolve(project);await Promise.all([old, latest]);
expect(store.getState().activeProjectId).toBe(second.id);
expect(store.getState().conversations[0].id).toBe('project-2-chat');
});