feat: integrate pixel teacher presence and resilient classroom preview
This commit is contained in:
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user