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

@@ -0,0 +1,207 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { CodingWorkPreviewService, workPageResponds } from '../../electron/coding-runtime/work-preview';
import { OPEN_WORK_PROMPT } from '../../shared/coding-work-preview';
import { firstMessageTitle } from '../../electron/coding-runtime/conversation-title';
import type { AgentBrowserSnapshot } from '../../shared/agent-browser';
import type { ConversationSnapshot } from '../../shared/coding-conversation-contracts';
import type { CodingConversationV2 } from '../../electron/coding-projects/conversation-store';
const project = { id: 'p1', path: '/project' };
const conversation = { id: 'c1', archivedAt: null } as CodingConversationV2;
function setup() {
const snapshot = { projectId: project.id, browserId: null, state: 'closed', url: '' } as AgentBrowserSnapshot;
const browser = { getSnapshot: vi.fn(async () => snapshot), open: vi.fn(async () => snapshot) };
const conversations = {
listConversations: vi.fn(async () => [conversation]),
getConversation: vi.fn(async () => conversation),
createConversation: vi.fn(async () => conversation),
getSnapshot: vi.fn(async () => ({ run: { status: 'idle', runId: 'work-run' } }) as ConversationSnapshot),
acceptPrompt: vi.fn(async () => ({ accepted: true as const, conversationId: 'c1', clientRequestId: 'work-preview-req', runId: 'work-run', mode: 'prompt' as const })),
};
const runtime = { getDiagnostics: vi.fn(() => ({ revision: { provider: 0, resources: 0 }, workers: [] })) };
const ensureActive = vi.fn(async () => {});
const responds = vi.fn(async () => false);
const service = new CodingWorkPreviewService({ browser, conversations, runtime, ensureActive, responds });
return { service, browser, conversations, runtime, ensureActive, responds, snapshot };
}
afterEach(() => vi.unstubAllGlobals());
describe('opening a work preview', () => {
it('lets a successful manual open supersede a pending startup without a late Agent request', async () => {
const s = setup();
let finish!: (value: ConversationSnapshot) => void;
s.conversations.getSnapshot.mockImplementationOnce(() => new Promise((resolve) => { finish = resolve; }));
const pending = s.service.ensure(project, 'req', 'c1');
await vi.waitFor(() => expect(s.conversations.getSnapshot).toHaveBeenCalled());
const healthy = { ...s.snapshot, browserId: 'manual', state: 'attached' as const, url: 'http://localhost:3000' };
s.service.remember(project, healthy);
s.responds.mockResolvedValue(true);
s.browser.open.mockResolvedValue(healthy);
expect(await s.service.ensure(project, 'req', 'c1')).toMatchObject({ status: 'ready' });
finish({ run: { status: 'idle' } } as ConversationSnapshot);
expect(await pending).toMatchObject({ status: 'ready' });
expect(s.conversations.acceptPrompt).not.toHaveBeenCalled();
});
it('restores a manually opened page after disposal without needing a model or sign-in', async () => {
const s = setup();
const healthy = { ...s.snapshot, browserId: 'manual', state: 'attached' as const, url: 'http://localhost:3000' };
s.service.remember(project, healthy);
s.browser.getSnapshot.mockResolvedValue({ ...s.snapshot, projectId: null });
s.browser.open.mockResolvedValue({ ...healthy, generation: 2 });
s.responds.mockResolvedValue(true);
s.conversations.getSnapshot.mockRejectedValue(new Error('login required'));
expect(await s.service.ensure(project, 'req', 'c1')).toMatchObject({ status: 'ready' });
expect(s.conversations.getSnapshot).not.toHaveBeenCalled();
expect(s.conversations.acceptPrompt).not.toHaveBeenCalled();
});
it('does not restore a remembered address under another project or path', async () => {
const s = setup();
s.service.remember(project, { ...s.snapshot, browserId: 'manual', state: 'attached', url: 'http://localhost:3000' });
s.responds.mockResolvedValue(true);
await s.service.ensure({ ...project, path: '/different' }, 'req', 'c1');
expect(s.browser.open).not.toHaveBeenCalled();
expect(s.responds).not.toHaveBeenCalled();
});
it('returns the login recovery cause after an accepted action fails, without automatically resending', async () => {
const s = setup();
await s.service.ensure(project, 'req', 'c1');
s.conversations.getSnapshot.mockResolvedValue({ run: { status: 'error', runId: 'work-run', error: { code: 'CODING_PROVIDER_AUTH_REQUIRED' } } } as ConversationSnapshot);
expect(await s.service.ensure(project, 'req', 'c1')).toMatchObject({ status: 'failed', errorCode: 'CODING_PROVIDER_AUTH_REQUIRED', message: expect.stringContaining('重新登录') });
await s.service.ensure(project, 'req', 'c1');
expect(s.conversations.acceptPrompt).toHaveBeenCalledTimes(1);
await s.service.ensure(project, 'after-login', 'c1');
expect(s.conversations.acceptPrompt).toHaveBeenCalledTimes(2);
});
it('reuses a healthy existing page without invoking the Agent or reopening the browser', async () => {
const s = setup();
Object.assign(s.snapshot, { browserId: 'b1', state: 'attached', url: 'http://localhost:3000' });
s.responds.mockResolvedValue(true);
expect(await s.service.ensure(project, 'req', 'c1')).toMatchObject({ status: 'ready', browser: s.snapshot });
expect(s.conversations.acceptPrompt).not.toHaveBeenCalled();
expect(s.browser.open).not.toHaveBeenCalled();
});
it('restores a crashed preview while keeping a responding service', async () => {
const s = setup();
Object.assign(s.snapshot, { state: 'crashed', url: 'http://localhost:3000' });
s.responds.mockResolvedValue(true);
await s.service.ensure(project, 'req', 'c1');
expect(s.browser.open).toHaveBeenCalledWith(expect.objectContaining({ url: s.snapshot.url, projectPath: project.path }));
expect(s.conversations.acceptPrompt).not.toHaveBeenCalled();
});
it('restores the last healthy project page after background sleep disposed the native view', async () => {
const s = setup();
const healthy = { ...s.snapshot, browserId: 'b1', state: 'attached' as const, url: 'http://localhost:3000' };
s.browser.getSnapshot.mockResolvedValue(healthy);
s.responds.mockResolvedValue(true);
await s.service.ensure(project, 'req', 'c1');
s.browser.getSnapshot.mockResolvedValue({ ...s.snapshot, projectId: null });
s.browser.open.mockResolvedValue({ ...healthy, browserId: 'restored', generation: 2 });
expect(await s.service.ensure(project, 'req', 'c1')).toMatchObject({ status: 'ready', browser: { browserId: 'restored' } });
expect(s.browser.open).toHaveBeenCalledWith({ projectId: 'p1', projectPath: '/project', url: healthy.url, visible: false });
expect(s.conversations.getSnapshot).not.toHaveBeenCalled();
expect(s.conversations.acceptPrompt).not.toHaveBeenCalled();
});
it('does not reuse a remembered page for a different project path or an unavailable server', async () => {
const s = setup();
s.browser.getSnapshot.mockResolvedValue({ ...s.snapshot, browserId: 'b1', state: 'attached', url: 'http://localhost:3000' });
s.responds.mockResolvedValue(true);
await s.service.ensure(project, 'req', 'c1');
s.browser.getSnapshot.mockResolvedValue({ ...s.snapshot, projectId: null });
s.responds.mockResolvedValue(false);
expect(await s.service.ensure(project, 'retry', 'c1')).toMatchObject({ status: 'starting' });
expect(s.browser.open).not.toHaveBeenCalled();
expect(s.conversations.acceptPrompt).toHaveBeenCalledTimes(1);
});
it('sends the fixed action once across simultaneous clicks and later polls', async () => {
const s = setup();
await Promise.all([s.service.ensure(project, 'req', 'c1'), s.service.ensure(project, 'other', 'c1')]);
s.conversations.getSnapshot.mockResolvedValue({ run: { status: 'running', runId: 'work-run' } } as ConversationSnapshot);
expect(await s.service.ensure(project, 'another', 'c1')).toMatchObject({ status: 'starting' });
expect(s.conversations.acceptPrompt).toHaveBeenCalledTimes(1);
expect(s.conversations.acceptPrompt).toHaveBeenCalledWith({ conversationId: 'c1', clientRequestId: 'work-preview-req', mode: 'prompt', text: OPEN_WORK_PROMPT });
Object.assign(s.snapshot, { browserId: 'b1', state: 'attached', url: 'http://localhost:3000' });
s.responds.mockResolvedValue(true);
expect(await s.service.ensure(project, 'req', 'c1')).toMatchObject({ status: 'ready' });
});
it('waits for current project work and then starts without steering the active run', async () => {
const s = setup();
s.runtime.getDiagnostics.mockReturnValueOnce({ revision: { provider: 0, resources: 0 }, workers: [{ conversationId: 'c1', stage: 'running' }] } as never);
expect(await s.service.ensure(project, 'req', 'c1')).toEqual({ status: 'waiting' });
expect(s.conversations.acceptPrompt).not.toHaveBeenCalled();
expect(await s.service.ensure(project, 'req', 'c1')).toMatchObject({ status: 'starting' });
expect(s.conversations.acceptPrompt).toHaveBeenCalledTimes(1);
});
it('settles failures without resubmission until an explicit retry', async () => {
const s = setup();
await s.service.ensure(project, 'req', 'c1');
expect(await s.service.ensure(project, 'req', 'c1')).toMatchObject({ status: 'failed' });
expect(await s.service.ensure(project, 'req', 'c1')).toMatchObject({ status: 'failed' });
expect(s.conversations.acceptPrompt).toHaveBeenCalledTimes(1);
await s.service.ensure(project, 'retry', 'c1');
expect(s.conversations.acceptPrompt).toHaveBeenCalledTimes(2);
});
it('does not automatically resend an uncertain prompt acceptance', async () => {
const s = setup();
s.conversations.acceptPrompt.mockRejectedValue(new Error('unknown receipt'));
expect(await s.service.ensure(project, 'req', 'c1')).toMatchObject({ status: 'failed' });
await s.service.ensure(project, 'req', 'c1');
expect(s.conversations.acceptPrompt).toHaveBeenCalledTimes(1);
});
it('waits for the accepted run instead of mistaking the preceding idle snapshot for completion', async () => {
const s = setup();
await s.service.ensure(project, 'req', 'c1');
s.conversations.getSnapshot.mockResolvedValue({ run: { status: 'idle', runId: 'old-run' } } as ConversationSnapshot);
expect(await s.service.ensure(project, 'req', 'c1')).toMatchObject({ status: 'starting' });
expect(s.conversations.acceptPrompt).toHaveBeenCalledTimes(1);
});
it('creates a conversation if needed without submitting a student draft', async () => {
const s = setup();
s.conversations.listConversations.mockResolvedValue([]);
expect(await s.service.ensure(project, 'req')).toMatchObject({ status: 'starting', conversation });
expect(s.conversations.createConversation).toHaveBeenCalledWith({ projectId: 'p1', title: '新对话' });
});
it('cancels dispatch when the project changes during a browser check', async () => {
const s = setup();
s.ensureActive.mockResolvedValueOnce().mockRejectedValue(new Error('project changed'));
await expect(s.service.ensure(project, 'req', 'c1')).rejects.toThrow('project changed');
expect(s.conversations.acceptPrompt).not.toHaveBeenCalled();
});
it('does not turn the internal action into a conversation title', () => {
expect(firstMessageTitle({ kind: 'message', id: 'system-action', role: 'user', status: 'complete', blocks: [{ kind: 'text', id: 'text', status: 'complete', text: OPEN_WORK_PROMPT }] })).toBeNull();
});
});
describe('known work page health', () => {
it('only checks loopback HTTP pages and cancels the body', async () => {
const cancel = vi.fn();
const fetch = vi.fn(async () => ({ ok: true, body: { cancel } }));
vi.stubGlobal('fetch', fetch);
expect(await workPageResponds('http://127.0.0.1:3000/game')).toBe(true);
expect(cancel).toHaveBeenCalled();
expect(await workPageResponds('https://example.com')).toBe(false);
expect(await workPageResponds('file:///project/index.html')).toBe(false);
expect(await workPageResponds('http://me:secret@localhost')).toBe(false);
expect(fetch).toHaveBeenCalledTimes(1);
expect(fetch).toHaveBeenCalledWith(expect.any(URL), expect.objectContaining({ redirect: 'manual', signal: expect.any(AbortSignal) }));
});
it('treats failed HTTP and connection errors as unavailable', async () => {
const fetch = vi.fn().mockResolvedValueOnce({ ok: false }).mockRejectedValueOnce(new Error('refused'));
vi.stubGlobal('fetch', fetch);
expect(await workPageResponds('http://localhost:3000')).toBe(false);
expect(await workPageResponds('http://localhost:3000')).toBe(false);
});
});