feat: integrate pixel teacher presence and resilient classroom preview

This commit is contained in:
鲨鱼辣椒
2026-09-23 18:23:53 +08:00
parent 12d800ec51
commit 7951cca700
60 changed files with 3313 additions and 180 deletions

View File

@@ -21,6 +21,7 @@ import {
} from '../../electron/coding-projects/project-config';
import { InMemoryConversationRuntime } from '../../electron/coding-runtime/in-memory-conversation-runtime';
import type { ConsultationRole, TeacherDefinition, TeacherRequestIntent, TeacherSourceContext, TeacherTopic } from '../../shared/coding-teacher';
import { TEACHER_CHECK_IN_INTERVAL_MS, TEACHER_UNCHANGED_CHECK_IN_INTERVAL_MS } from '../../shared/coding-teacher';
import type { ConversationSnapshot } from '../../shared/coding-conversation-contracts';
import { parseNianCodeDeepLinkUrl } from '../../electron/main/app-deep-link';
@@ -55,6 +56,7 @@ 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 })));
vi.useRealTimers();
});
async function fixture() {
const root = await mkdtemp(path.join(tmpdir(), 'coding-teacher-'));
@@ -94,7 +96,7 @@ async function fixture() {
};
const readSource = vi.fn(async (_scope: TeacherScope) => structuredClone(context));
const prepareModel = vi.fn(async () => ({ inputLimit: 8000, run }));
const service = new CodingTeacherService({
const createService = () => new CodingTeacherService({
projects,
runtime: new InMemoryConversationRuntime(),
userDataDir: root,
@@ -114,6 +116,7 @@ async function fixture() {
readSource,
prepareModel,
});
const service = createService();
services.push(service);
return {
root,
@@ -124,6 +127,12 @@ async function fixture() {
run,
readSource,
prepareModel,
restart: async () => {
await service.dispose();
const restarted = createService();
services.push(restarted);
return restarted;
},
finish: () => finish(),
replyWith: (text: string) => { reply = text; },
disable: () => {
@@ -144,6 +153,13 @@ async function fixture() {
};
}
describe('cloud coding teacher', () => {
it('does not change the selected discussion when the companion polls an older topic', async () => {
const f = await fixture();
const old = await f.service.create(f.scope);
const selected = await f.service.create(f.scope);
expect((await f.service.read(f.scope, old.id, false)).id).toBe(old.id);
expect((await f.service.list(f.scope)).lastSelectedTopicId).toBe(selected.id);
});
it('persists a fixed version, deduplicates requests, and does not change coding metadata', async () => {
const f = await fixture();
const before = await readFile(
@@ -678,6 +694,260 @@ describe('teacher contextual discussion entry points', () => {
});
});
describe('project teacher check-ins', () => {
const requestId = '22222222-2222-4222-8222-222222222222';
const nextRequestId = '33333333-3333-4333-8333-333333333333';
const thirdRequestId = '44444444-4444-4444-8444-444444444444';
const projectScope = (f: Awaited<ReturnType<typeof fixture>>): TeacherScope => ({
projectId: f.scope.projectId, sourceId: 'project', role: 'teacher',
});
const input = (f: Awaited<ReturnType<typeof fixture>>, id = requestId) => ({
requestId: id, sourceConversationId: f.scope.sourceId,
});
const settle = async (f: Awaited<ReturnType<typeof fixture>>, topic: TeacherTopic) => {
f.finish();
await vi.waitFor(async () => expect((await f.service.read(projectScope(f), topic.id)).requests.at(-1)?.status).toBe('completed'));
return await f.service.read(projectScope(f), topic.id);
};
const startClock = () => {
vi.useFakeTimers({ toFake: ['Date'] });
vi.setSystemTime(new Date('2026-09-22T12:00:00Z'));
};
it('continues the selected teacher history with a real configured-model call and no fabricated student turn', async () => {
const f = await fixture();
const scope = projectScope(f);
const selected = await f.service.create(scope);
await f.service.send(scope, selected.id, { requestId, text: '变量是什么意思?' });
await settle(f, selected);
await f.service.create(scope);
await f.service.read(scope, selected.id);
f.replyWith('你已经在试着保存数字了。想想这个数字要在什么时候改变?');
const result = await f.service.checkIn(scope, input(f, nextRequestId));
expect(result.topic?.id).toBe(selected.id);
const saved = await settle(f, result.topic!);
expect(saved.requests.at(-1)).toMatchObject({
intent: 'check-in', text: '', references: [], sourceConversationId: f.scope.sourceId, status: 'completed',
});
expect(saved.requests.at(-1)?.checkInSourceFingerprint).toMatch(/^[a-f0-9]{64}$/);
const messages = f.run.mock.calls[1][0];
expect(messages[0].content).toContain('通过问题引导思考');
expect(messages[0].content).toContain('使用具体的小例子');
expect(JSON.stringify(messages)).toContain('创建计数器');
expect(JSON.stringify(messages)).toContain('变量是什么意思');
expect(messages.at(-1)).toMatchObject({ role: 'system' });
expect(messages.at(-1).content).toContain('约 120 字');
expect(messages.at(-1).content).toContain('最多问一个问题');
expect(messages.at(-1).content).toContain('不能假装');
expect(f.prepareModel).toHaveBeenLastCalledWith(expect.anything(), selected.definition);
const followup = compileTeacherContext(saved.definition, context, saved.requests, '继续说', []);
expect(followup.messages.filter((message) => message.role === 'user').map((message) => message.content))
.toEqual([expect.stringContaining('创建计数器'), '变量是什么意思?', '当前问题:\n继续说']);
expect(followup.messages.filter((message) => message.role === 'assistant').map((message) => message.content))
.toContain(saved.requests.at(-1)?.response);
});
it('serializes first-topic creation, returns the same accepted request, and skips a concurrent distinct check-in', async () => {
const f = await fixture();
const scope = projectScope(f);
const [first, duplicate, competing] = await Promise.all([
f.service.checkIn(scope, input(f)), f.service.checkIn(scope, input(f)),
f.service.checkIn(scope, input(f, nextRequestId)),
]);
expect(first.topic?.id).toBe(duplicate.topic?.id);
expect(first.topic?.requests).toHaveLength(1);
expect(competing).toEqual({ topic: null, skipped: 'busy' });
expect(f.run).toHaveBeenCalledOnce();
expect((await f.service.list(scope)).items).toHaveLength(1);
expect((await f.service.list(scope)).items[0].title).toBe('和老师聊聊');
await f.service.create(scope);
expect((await f.service.checkIn(scope, input(f))).topic?.id).toBe(first.topic?.id);
await expect(f.service.checkIn(scope, { ...input(f), sourceConversationId: thirdRequestId }))
.rejects.toMatchObject({ code: 'teacher_request_conflict' });
});
it('enforces project-wide cooldown and ignores cursor churn until completed text changes', async () => {
startClock();
const f = await fixture();
const scope = projectScope(f);
const first = await f.service.checkIn(scope, input(f));
await settle(f, first.topic!);
await f.service.create(scope);
expect(await f.service.checkIn(scope, input(f, nextRequestId))).toEqual({ topic: null, skipped: 'cooldown' });
vi.setSystemTime(Date.now() + TEACHER_CHECK_IN_INTERVAL_MS);
f.readSource.mockResolvedValue({ ...context, cursor: { workerGeneration: 90, seq: 500 } });
expect(await f.service.checkIn(scope, input(f, nextRequestId))).toEqual({ topic: null, skipped: 'unchanged' });
expect(f.run).toHaveBeenCalledOnce();
f.readSource.mockResolvedValue({ ...context, messages: [...context.messages, { id: 'new', role: 'assistant', text: '添加了重置按钮。' }] });
const next = await f.service.checkIn(scope, input(f, nextRequestId));
expect(next.topic?.id).not.toBe(first.topic?.id);
expect(f.run).toHaveBeenCalledTimes(2);
expect(JSON.stringify(f.run.mock.calls[1][0])).toContain('添加了重置按钮');
});
it('uses persisted history for request deduplication, cooldown, and unchanged-source checks after restart', async () => {
startClock();
const f = await fixture();
const scope = projectScope(f);
const first = await f.service.checkIn(scope, input(f));
await settle(f, first.topic!);
const restarted = await f.restart();
expect((await restarted.checkIn(scope, input(f))).topic?.id).toBe(first.topic?.id);
expect(await restarted.checkIn(scope, input(f, nextRequestId))).toEqual({ topic: null, skipped: 'cooldown' });
vi.setSystemTime(Date.now() + TEACHER_CHECK_IN_INTERVAL_MS);
expect(await restarted.checkIn(scope, input(f, nextRequestId))).toEqual({ topic: null, skipped: 'unchanged' });
expect(f.run).toHaveBeenCalledOnce();
});
it('follows up on unchanged work after fifteen minutes instead of suppressing that source forever', async () => {
startClock();
const f = await fixture();
const scope = projectScope(f);
const first = await f.service.checkIn(scope, input(f));
await settle(f, first.topic!);
const restarted = await f.restart();
vi.setSystemTime(Date.parse(first.topic!.requests[0].createdAt) + TEACHER_UNCHANGED_CHECK_IN_INTERVAL_MS - 1);
expect(await restarted.checkIn(scope, input(f, nextRequestId))).toEqual({ topic: null, skipped: 'unchanged' });
vi.setSystemTime(Date.now() + 1);
expect((await restarted.checkIn(scope, input(f, nextRequestId))).topic?.id).toBe(first.topic?.id);
expect(f.run).toHaveBeenCalledTimes(2);
});
it('counts a new teacher discussion as progress even when operation text has not changed', async () => {
startClock();
const f = await fixture();
const scope = projectScope(f);
const first = await f.service.checkIn(scope, input(f));
await settle(f, first.topic!);
await f.service.send(scope, first.topic!.id, { requestId: nextRequestId, sourceConversationId: f.scope.sourceId, text: '我想让宠物跳起来的时候有个小惊喜' });
await settle(f, first.topic!);
vi.setSystemTime(Date.now() + TEACHER_CHECK_IN_INTERVAL_MS);
expect((await f.service.checkIn(scope, input(f, thirdRequestId))).topic?.id).toBe(first.topic?.id);
expect(f.run).toHaveBeenCalledTimes(3);
expect(JSON.stringify(f.run.mock.calls[2][0])).toContain('小惊喜');
});
it('can follow an existing teacher discussion before the first completed operation message', async () => {
const f = await fixture();
const scope = projectScope(f);
const topic = await f.service.create(scope);
await f.service.send(scope, topic.id, { requestId, sourceConversationId: f.scope.sourceId, text: '我想做一个养宠物的游戏' });
await settle(f, topic);
f.readSource.mockResolvedValue({ ...context, messages: [] });
expect((await f.service.checkIn(scope, input(f, nextRequestId))).topic?.id).toBe(topic.id);
expect(f.run).toHaveBeenCalledTimes(2);
});
it('does not create a topic or call a model without completed source text', async () => {
const f = await fixture();
f.readSource.mockResolvedValue({ ...context, messages: [] });
expect(await f.service.checkIn(projectScope(f), input(f))).toEqual({ topic: null, skipped: 'no-context' });
expect((await f.service.list(projectScope(f))).items).toEqual([]);
expect(f.prepareModel).not.toHaveBeenCalled();
expect(f.run).not.toHaveBeenCalled();
});
it('skips archived or disabled sources and rejects sources outside the project before reading context', async () => {
const f = await fixture();
const scope = projectScope(f);
await expect(f.service.checkIn(scope, { ...input(f), sourceConversationId: thirdRequestId }))
.rejects.toMatchObject({ code: 'teacher_source_not_found' });
const conversations = f.projects.conversationStore(f.created.project.path);
await conversations.patchMetadata(f.scope.sourceId, { archivedAt: new Date().toISOString() });
expect(await f.service.checkIn(scope, input(f))).toEqual({ topic: null, skipped: 'archived' });
await conversations.patchMetadata(f.scope.sourceId, { archivedAt: null });
f.disable();
expect(await f.service.checkIn(scope, input(f))).toEqual({ topic: null, skipped: 'disabled' });
expect(f.readSource).not.toHaveBeenCalled();
expect(f.prepareModel).not.toHaveBeenCalled();
});
it('rejects friend, preview, legacy-source scopes and ordinary messages that try to bypass check-in guards', async () => {
const f = await fixture();
for (const scope of [{ ...projectScope(f), role: 'friend' as const }, { projectId: 'preview', sourceId: 'preview' }, f.scope]) {
await expect(f.service.checkIn(scope, input(f))).rejects.toMatchObject({ code: 'teacher_intent_invalid' });
}
const scope = projectScope(f);
const topic = await f.service.create(scope);
await expect(f.service.send(scope, topic.id, { ...input(f), intent: 'check-in', text: '' }))
.rejects.toMatchObject({ code: 'teacher_intent_invalid' });
expect(f.readSource).not.toHaveBeenCalled();
expect(f.prepareModel).not.toHaveBeenCalled();
});
it('waits for manual request preparation and skips while any teacher topic is answering', async () => {
const f = await fixture();
const scope = projectScope(f);
const answering = await f.service.create(scope);
await f.service.create(scope);
let releaseSource!: (source: TeacherSourceContext) => void;
f.readSource.mockImplementationOnce(() => new Promise((resolve) => { releaseSource = resolve; }));
const manual = f.service.send(scope, answering.id, { requestId, text: '解释一下', sourceConversationId: f.scope.sourceId });
await vi.waitFor(() => expect(f.readSource).toHaveBeenCalledOnce());
const check = f.service.checkIn(scope, input(f, nextRequestId));
releaseSource(context);
await manual;
expect(await check).toEqual({ topic: null, skipped: 'busy' });
expect(f.run).toHaveBeenCalledOnce();
});
it('does not persist or run a check-in when the account changes during preparation', async () => {
const f = await fixture();
f.prepareModel.mockImplementationOnce(async () => {
f.switchAccount();
return { inputLimit: 8000, run: f.run };
});
await expect(f.service.checkIn(projectScope(f), input(f))).rejects.toMatchObject({ code: 'teacher_account_changed' });
expect(f.run).not.toHaveBeenCalled();
const list = await f.service.list(projectScope(f));
expect((await f.service.read(projectScope(f), list.items[0].id)).requests).toEqual([]);
});
it('cancels an accepted check-in when its source is deleted and never restarts it', async () => {
const f = await fixture();
const scope = projectScope(f);
const first = await f.service.checkIn(scope, input(f));
await f.service.removeSource(f.scope.projectId, f.scope.sourceId);
expect((await f.service.read(scope, first.topic!.id)).requests[0].status).toBe('cancelled');
expect((await f.service.checkIn(scope, input(f))).topic?.requests[0].status).toBe('cancelled');
expect(f.run).toHaveBeenCalledOnce();
});
});
it('accepts teacher check-ins through the project endpoint and returns skip results without a model call', 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 url = `http://127.0.0.1:${address.port}/api/coding/projects/${f.scope.projectId}/teacher-check-in`;
const input = { requestId: '22222222-2222-4222-8222-222222222222', sourceConversationId: f.scope.sourceId };
const post = () => fetch(url, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input),
});
try {
expect((await fetch(url)).status).toBe(405);
f.readSource.mockResolvedValueOnce({ ...context, messages: [] });
const skipped = await post();
expect(skipped.status).toBe(200);
expect(await skipped.json()).toEqual({ topic: null, skipped: 'no-context' });
expect(f.run).not.toHaveBeenCalled();
const accepted = await post();
expect(accepted.status).toBe(200);
const result = await accepted.json();
expect(result.topic.requests[0]).toMatchObject({ intent: 'check-in', text: '', status: 'running' });
expect((await (await post()).json()).topic.id).toBe(result.topic.id);
expect(f.run).toHaveBeenCalledOnce();
} finally {
server.closeAllConnections();
await new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
}
});
describe('teacher context and wire contract', () => {
it('takes only complete user/assistant text and preserves the read cursor', () => {
const snapshot = {