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

@@ -665,6 +665,64 @@ describe('coding Conversation store', () => {
expect(store.getState().draftsByConversationId['conversation-a'].text).toBe('');
});
it.each(['completed', 'aborted', 'failed'] as const)('settles an accepted prompt from its own %s run without a request-tagged history node', async (terminalReason) => {
const store = createCodingConversationStore({
submitPrompt: vi.fn(async (input) => acceptance(input.conversationId, input.clientRequestId)),
createId: ids(),
});
store.getState().applySnapshotEvent(snapshotEvent(snapshot('conversation-a')));
store.getState().setDraft('conversation-a', 'hello');
await store.getState().submitPrompt({ conversationId: 'conversation-a', mode: 'prompt' });
store.getState().setDraft('conversation-a', '我的游戏在哪里哦');
const settled = snapshot('conversation-a', 1, 1);
settled.run = { status: terminalReason === 'failed' ? 'error' : 'idle', runId: 'run-1', terminalReason, settledAt: 2_000 };
store.getState().applySnapshotEvent(snapshotEvent(settled));
expect(store.getState().requestsByConversationId['conversation-a']).toEqual({});
expect(selectCodingConversationSnapshot('conversation-a')(store.getState())?.nodes).toEqual(settled.nodes);
expect(store.getState().draftsByConversationId['conversation-a'].text).toBe('我的游戏在哪里哦');
});
it('settles a prompt when its terminal snapshot arrives before the HTTP acceptance', async () => {
const pending = deferred<PromptAcceptance>();
const store = createCodingConversationStore({ submitPrompt: vi.fn(() => pending.promise), createId: ids() });
store.getState().applySnapshotEvent(snapshotEvent(snapshot('conversation-a')));
store.getState().setDraft('conversation-a', 'hello');
const submission = store.getState().submitPrompt({ conversationId: 'conversation-a', mode: 'prompt' });
const settled = snapshot('conversation-a', 1, 1);
settled.run = { status: 'idle', runId: 'run-1', terminalReason: 'completed', settledAt: 2_000 };
store.getState().applySnapshotEvent(snapshotEvent(settled));
expect(store.getState().requestsByConversationId['conversation-a']['request-1'].status).toBe('pending');
store.getState().setDraft('conversation-a', '保留新的问题');
pending.resolve(acceptance('conversation-a', 'request-1'));
await submission;
expect(store.getState().requestsByConversationId['conversation-a']).toEqual({});
expect(selectCodingConversationSnapshot('conversation-a')(store.getState())?.nodes).toEqual(settled.nodes);
expect(store.getState().draftsByConversationId['conversation-a'].text).toBe('保留新的问题');
});
it('does not treat an old idle snapshot or a locally optimistic node in an unrelated patch as delivery evidence', async () => {
const pending = deferred<PromptAcceptance>();
const store = createCodingConversationStore({ submitPrompt: vi.fn(() => pending.promise), createId: ids() });
store.getState().applySnapshotEvent(snapshotEvent(snapshot('conversation-a')));
store.getState().setDraft('conversation-a', 'hello');
const submission = store.getState().submitPrompt({ conversationId: 'conversation-a', mode: 'prompt' });
const previous = snapshot('conversation-a', 1, 1);
previous.run = { status: 'idle', runId: 'previous-run', terminalReason: 'completed', settledAt: 1_000 };
store.getState().applySnapshotEvent(snapshotEvent(previous));
store.getState().applyPatchBatchEvent(patchEvent('conversation-a', 2, { op: 'worker.state', state: { status: 'ready', generation: 1 } }));
expect(store.getState().requestsByConversationId['conversation-a']['request-1'].status).toBe('pending');
pending.resolve(acceptance('conversation-a', 'request-1'));
await submission;
expect(store.getState().requestsByConversationId['conversation-a']['request-1']).toMatchObject({ status: 'accepted', runId: 'run-1' });
store.getState().applySnapshotEvent(snapshotEvent({ ...previous, cursor: { workerGeneration: 1, seq: 3 } }));
expect(store.getState().requestsByConversationId['conversation-a']['request-1'].status).toBe('accepted');
store.getState().applyPatchBatchEvent(patchEvent('conversation-a', 4, { op: 'run.state', run: { status: 'idle', runId: 'run-1' } }));
expect(store.getState().requestsByConversationId['conversation-a']['request-1'].status).toBe('accepted');
store.getState().applyPatchBatchEvent(patchEvent('conversation-a', 5, { op: 'run.state', run: { status: 'idle', runId: 'run-1', terminalReason: 'completed', settledAt: 2_000 } }));
expect(store.getState().requestsByConversationId['conversation-a']).toEqual({});
expect(selectCodingConversationSnapshot('conversation-a')(store.getState())?.nodes.some((node) => node.kind === 'message' && node.status === 'optimistic')).toBe(false);
});
it('preserves the optimistic UI id when a reconnect snapshot is already durable', async () => {
const store = createCodingConversationStore({
getSnapshot: vi.fn(),