# Speaking Student Session History Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Formal student mode restores the latest speaking session for the same `configId + userId`, while editor preview modes keep creating isolated preview sessions. **Architecture:** Backend persists `config_id` on `dialogue_session`, keeps `POST /session` as always-create, and adds a latest lookup endpoint that returns session metadata plus message history. Frontend reads URL `mode` and `userid` inside `TopicDiscussionPreview`, only calls latest lookup for `mode=student`, and initializes `DialogueChatView` with historical messages for active sessions. **Tech Stack:** Vue 3 + TypeScript + Pinia frontend in `/Users/buoy/Development/gitrepo/PPT`; FastAPI + SQLAlchemy async + pytest backend in `/Users/buoy/Development/gitrepo/cococlass-english-speaking-api`. --- ## File Structure Backend repository: `/Users/buoy/Development/gitrepo/cococlass-english-speaking-api` - Modify: `app/models/dialogue.py` - Add nullable `DialogueSession.config_id`. - Modify: `init.sql` - Add `config_id` to fresh installs and add lookup index. - Create: `migrations/2026-04-27-add-dialogue-session-config-id.sql` - Manual migration for existing deployments. - Modify: `app/service/speaking/dialogue_service.py` - Accept/store `config_id`. - Add helper to adapt `DialogueMessage` history for latest response. - Modify: `app/api/dialogue.py` - Accept `configId` in create request. - Add `GET /sessions/latest` route. - Modify: `tests/api/test_dialogue_greeting.py` - Cover create storage and latest lookup behavior using the existing ASGI test harness. Frontend repository: `/Users/buoy/Development/gitrepo/PPT` - Modify: `src/types/englishSpeaking.ts` - Add `configId`, `userId`, historical message/session types, and `checking-history` preview state. - Modify: `src/views/Editor/EnglishSpeaking/services/llmService.ts` - Send `configId/userId` on create. - Add `getLatestSession(configId, userId)`. - Modify: `src/views/Editor/EnglishSpeaking/composables/useDialogueEngine.ts` - Let `attachSession` seed initial messages and current round. - Modify: `src/views/Editor/EnglishSpeaking/preview/DialogueChatView.vue` - Pass historical messages into the engine. - Skip greeting generation when history already includes messages. - Modify: `src/views/Editor/EnglishSpeaking/preview/TopicDiscussionPreview.vue` - Read URL `mode/userid`. - Query latest only in `mode=student`. - Open active history in chat and completed history in report. --- ## Task 1: Backend Session Ownership Schema **Files:** - Modify: `/Users/buoy/Development/gitrepo/cococlass-english-speaking-api/app/models/dialogue.py` - Modify: `/Users/buoy/Development/gitrepo/cococlass-english-speaking-api/init.sql` - Create: `/Users/buoy/Development/gitrepo/cococlass-english-speaking-api/migrations/2026-04-27-add-dialogue-session-config-id.sql` - Test: `/Users/buoy/Development/gitrepo/cococlass-english-speaking-api/tests/api/test_dialogue_greeting.py` - [ ] **Step 1: Add failing API test for create storing `config_id` and `user_id`** Append this test to `tests/api/test_dialogue_greeting.py`: ```python @pytest.mark.asyncio async def test_post_session_stores_config_and_user(test_env): client, SessionLocal = test_env r = await client.post("/api/speaking/dialogue/session", json={ "topic": "Animals", "grade": "grade5-1", "totalRounds": 3, "configId": "config-abc", "userId": "student-001", }) assert r.status_code == 200 async with SessionLocal() as db: result = await db.execute(select(DialogueSession)) session = result.scalar_one() assert session.config_id == "config-abc" assert session.user_id == "student-001" ``` - [ ] **Step 2: Run test to verify it fails** Run: ```bash uv run pytest tests/api/test_dialogue_greeting.py::test_post_session_stores_config_and_user -q ``` Expected: fail with an attribute error or assertion failure because `DialogueSession.config_id` does not exist or is not populated. - [ ] **Step 3: Add ORM field** In `app/models/dialogue.py`, add `config_id` directly after `user_id`: ```python user_id: Mapped[Optional[str]] = mapped_column(String(64), nullable=True) config_id: Mapped[Optional[str]] = mapped_column(String(36), nullable=True, index=True) topic: Mapped[str] = mapped_column(String(255)) ``` - [ ] **Step 4: Update fresh install schema** In `init.sql`, change `dialogue_session` to include `config_id` after `user_id`, and add the lookup index: ```sql user_id VARCHAR(64) NULL, config_id VARCHAR(36) NULL, topic VARCHAR(255) NOT NULL, ``` Add this index after `UNIQUE INDEX uk_uuid (uuid)`: ```sql INDEX idx_dialogue_session_config_user_created (config_id, user_id, created_at) ``` The resulting tail of `dialogue_session` should have comma placement like: ```sql completed_at DATETIME NULL, UNIQUE INDEX uk_uuid (uuid), INDEX idx_dialogue_session_config_user_created (config_id, user_id, created_at) ); ``` - [ ] **Step 5: Add existing deployment migration** Create `migrations/2026-04-27-add-dialogue-session-config-id.sql`: ```sql USE speaking; ALTER TABLE dialogue_session ADD COLUMN config_id VARCHAR(36) NULL AFTER user_id; CREATE INDEX idx_dialogue_session_config_user_created ON dialogue_session (config_id, user_id, created_at); ``` - [ ] **Step 6: Thread `config_id` through service** In `app/service/speaking/dialogue_service.py`, update the `create_session_only` signature: ```python async def create_session_only( self, db: AsyncSession, topic: str, grade: str, vocabulary: list[str], sentences: list[str], total_rounds: int = 3, duration_seconds: int | None = None, role_config: dict | None = None, user_id: str | None = None, config_id: str | None = None, ) -> dict: ``` In the `DialogueSession(...)` constructor, add: ```python user_id=user_id, config_id=config_id, topic=topic, ``` - [ ] **Step 7: Accept `configId` in create API** In `app/api/dialogue.py`, update `CreateSessionRequest`: ```python class CreateSessionRequest(BaseModel): topic: str grade: str vocabulary: list[str] = [] sentences: list[str] = [] totalRounds: int = 3 # 时长(分钟)。与 totalRounds 是并存的两个结束条件,先满足谁就结束。 # 后端据此计算并持久化 expires_at,一经写入不会被修改 —— 关页面/刷新/换设备重进同一 session 都接续同一截止时刻。 durationMinutes: int | None = None roleId: str | None = None userId: str | None = None configId: str | None = None ``` In `create_session`, pass `config_id=req.configId`: ```python result = await service.create_session_only( db=db, topic=req.topic, grade=req.grade, vocabulary=req.vocabulary, sentences=req.sentences, total_rounds=req.totalRounds, duration_seconds=duration_seconds, user_id=req.userId, config_id=req.configId, ) ``` - [ ] **Step 8: Run the targeted backend test** Run: ```bash uv run pytest tests/api/test_dialogue_greeting.py::test_post_session_stores_config_and_user -q ``` Expected: pass. - [ ] **Step 9: Commit backend schema/create changes** Run: ```bash git add app/models/dialogue.py app/service/speaking/dialogue_service.py app/api/dialogue.py init.sql migrations/2026-04-27-add-dialogue-session-config-id.sql tests/api/test_dialogue_greeting.py git commit -m "feat: store speaking config on dialogue sessions" ``` --- ## Task 2: Backend Latest Session Lookup **Files:** - Modify: `/Users/buoy/Development/gitrepo/cococlass-english-speaking-api/app/api/dialogue.py` - Modify: `/Users/buoy/Development/gitrepo/cococlass-english-speaking-api/app/service/speaking/dialogue_service.py` - Test: `/Users/buoy/Development/gitrepo/cococlass-english-speaking-api/tests/api/test_dialogue_greeting.py` - [ ] **Step 1: Add failing latest lookup tests** Append these tests to `tests/api/test_dialogue_greeting.py`: ```python @pytest.mark.asyncio async def test_latest_session_returns_null_when_missing(test_env): client, _ = test_env r = await client.get( "/api/speaking/dialogue/sessions/latest", params={"configId": "missing-config", "userId": "student-001"}, ) assert r.status_code == 200 assert r.json() == {"session": None} @pytest.mark.asyncio async def test_latest_session_returns_newest_exact_config_user_pair(test_env): client, SessionLocal = test_env older = await client.post("/api/speaking/dialogue/session", json={ "topic": "Older", "grade": "grade5-1", "totalRounds": 2, "configId": "config-abc", "userId": "student-001", }) newer = await client.post("/api/speaking/dialogue/session", json={ "topic": "Newer", "grade": "grade5-1", "totalRounds": 4, "configId": "config-abc", "userId": "student-001", }) other_user = await client.post("/api/speaking/dialogue/session", json={ "topic": "Other user", "grade": "grade5-1", "totalRounds": 9, "configId": "config-abc", "userId": "student-002", }) other_config = await client.post("/api/speaking/dialogue/session", json={ "topic": "Other config", "grade": "grade5-1", "totalRounds": 8, "configId": "config-other", "userId": "student-001", }) assert older.status_code == newer.status_code == other_user.status_code == other_config.status_code == 200 expected_session_id = newer.json()["sessionId"] r = await client.get( "/api/speaking/dialogue/sessions/latest", params={"configId": "config-abc", "userId": "student-001"}, ) assert r.status_code == 200 body = r.json() assert body["session"]["sessionId"] == expected_session_id assert body["session"]["status"] == "active" assert body["session"]["totalRounds"] == 4 assert body["session"]["currentRound"] == 1 assert body["session"]["messages"] == [] @pytest.mark.asyncio async def test_latest_session_includes_message_history(test_env): client, _ = test_env created = await client.post("/api/speaking/dialogue/session", json={ "topic": "History", "grade": "grade5-1", "totalRounds": 3, "configId": "config-history", "userId": "student-001", }) session_id = created.json()["sessionId"] greeting = await client.post( f"/api/speaking/dialogue/session/{session_id}/greeting", json={"turnId": "history-greeting-turn"}, ) assert greeting.status_code == 200 latest = await client.get( "/api/speaking/dialogue/sessions/latest", params={"configId": "config-history", "userId": "student-001"}, ) assert latest.status_code == 200 messages = latest.json()["session"]["messages"] assert messages == [ { "id": messages[0]["id"], "round": 1, "role": "ai", "content": "Hi! Ready to talk?", "audioUrl": None, "clientTurnId": "history-greeting-turn", } ] ``` - [ ] **Step 2: Run tests to verify they fail** Run: ```bash uv run pytest tests/api/test_dialogue_greeting.py::test_latest_session_returns_null_when_missing tests/api/test_dialogue_greeting.py::test_latest_session_returns_newest_exact_config_user_pair tests/api/test_dialogue_greeting.py::test_latest_session_includes_message_history -q ``` Expected: fail with 404 because `/api/speaking/dialogue/sessions/latest` does not exist. - [ ] **Step 3: Add message/session adapter helper** In `app/service/speaking/dialogue_service.py`, add this method inside `DialogueService`, immediately before `get_report`: ```python async def get_latest_session_for_config_user( self, db: AsyncSession, config_id: str, user_id: str, ) -> dict: result = await db.execute( select(DialogueSession) .where(DialogueSession.config_id == config_id) .where(DialogueSession.user_id == user_id) .order_by(DialogueSession.created_at.desc(), DialogueSession.id.desc()) .limit(1) ) session = result.scalar_one_or_none() if session is None: return {"session": None} messages_result = await db.execute( select(DialogueMessage) .where(DialogueMessage.session_id == session.id) .order_by(DialogueMessage.created_at, DialogueMessage.id) ) messages = messages_result.scalars().all() return { "session": { "sessionId": session.uuid, "status": session.status, "totalRounds": session.total_rounds, "currentRound": session.current_round, "expiresAt": session.expires_at.isoformat() if session.expires_at else None, "createdAt": session.created_at.isoformat() if session.created_at else None, "completedAt": session.completed_at.isoformat() if session.completed_at else None, "messages": [ { "id": msg.uuid, "round": msg.round, "role": msg.role, "content": msg.content, "audioUrl": msg.audio_url, "clientTurnId": msg.client_turn_id, } for msg in messages ], } } ``` - [ ] **Step 4: Add latest route** In `app/api/dialogue.py`, add this route after `create_session` and before `/session/{session_id}/greeting` so fixed paths remain unambiguous: ```python @router.get("/sessions/latest") async def get_latest_session( configId: str, userId: str, db: AsyncSession = Depends(get_db), service: DialogueService = Depends(get_dialogue_service), ): """Return the latest session for one speaking config and one student.""" if not configId.strip(): raise HTTPException(status_code=400, detail="configId is required") if not userId.strip(): raise HTTPException(status_code=400, detail="userId is required") return await service.get_latest_session_for_config_user( db=db, config_id=configId, user_id=userId, ) ``` - [ ] **Step 5: Run latest lookup tests** Run: ```bash uv run pytest tests/api/test_dialogue_greeting.py::test_latest_session_returns_null_when_missing tests/api/test_dialogue_greeting.py::test_latest_session_returns_newest_exact_config_user_pair tests/api/test_dialogue_greeting.py::test_latest_session_includes_message_history -q ``` Expected: pass. - [ ] **Step 6: Run all backend API tests touched by this feature** Run: ```bash uv run pytest tests/api/test_dialogue_greeting.py tests/api/test_dialogue_task_hint.py -q ``` Expected: pass. - [ ] **Step 7: Commit latest lookup** Run: ```bash git add app/api/dialogue.py app/service/speaking/dialogue_service.py tests/api/test_dialogue_greeting.py git commit -m "feat: add latest speaking session lookup" ``` --- ## Task 3: Frontend API Types and Engine History Seeding **Files:** - Modify: `/Users/buoy/Development/gitrepo/PPT/src/types/englishSpeaking.ts` - Modify: `/Users/buoy/Development/gitrepo/PPT/src/views/Editor/EnglishSpeaking/services/llmService.ts` - Modify: `/Users/buoy/Development/gitrepo/PPT/src/views/Editor/EnglishSpeaking/composables/useDialogueEngine.ts` - Modify: `/Users/buoy/Development/gitrepo/PPT/src/views/Editor/EnglishSpeaking/preview/DialogueChatView.vue` - [ ] **Step 1: Extend frontend types** In `src/types/englishSpeaking.ts`, update `SessionConfig`: ```ts export interface SessionConfig { topic: string grade: string roleId: string totalRounds: number /** 时长(分钟)。与 totalRounds 是并存的两个结束条件,后端据此计算 expiresAt */ durationMinutes: number vocabulary?: string[] sentences?: string[] configId?: string | null userId?: string | null } ``` Add these interfaces after `SessionStartInfo`: ```ts export interface HistoricalDialogueMessage { id: string round: number role: 'ai' | 'student' content: string audioUrl?: string | null clientTurnId?: string | null } export interface LatestSessionInfo extends SessionStartInfo { status: 'active' | 'completed' | 'abandoned' totalRounds: number currentRound: number createdAt: string | null completedAt: string | null messages: HistoricalDialogueMessage[] } export interface LatestSessionResponse { session: LatestSessionInfo | null } ``` Update `SessionStartInfo`: ```ts export interface SessionStartInfo { sessionId: string expiresAt: string | null currentRound?: number messages?: HistoricalDialogueMessage[] } ``` Update preview state: ```ts export type PreviewDialogueState = 'checking-history' | 'ready' | 'chatting' | 'completed' ``` Update `DialogueAPI`: ```ts export interface DialogueAPI { createSession(config: SessionConfig): Promise getLatestSession(configId: string, userId: string): Promise completeSession(sessionId: string): Promise /** Throws DOMException('AbortError') on signal abort; throws DialogueApiError on non-OK HTTP. */ generateGreeting(sessionId: string, turnId: string, signal?: AbortSignal): Promise generateTaskHint(sessionId: string): Promise speak(sessionId: string, audioBlob: Blob, signal: AbortSignal, turnId: string): AsyncGenerator getReport(sessionId: string): Promise } ``` - [ ] **Step 2: Extend real API client** In `src/views/Editor/EnglishSpeaking/services/llmService.ts`, add `LatestSessionResponse` to the type import list. In `RealDialogueAPI.createSession`, include `configId` and `userId` in the JSON body: ```ts body: JSON.stringify({ topic: config.topic, grade: config.grade, vocabulary: config.vocabulary ?? [], sentences: config.sentences ?? [], totalRounds: config.totalRounds, durationMinutes: config.durationMinutes, roleId: config.roleId, configId: config.configId ?? null, userId: config.userId ?? null, }), ``` Add this method in `RealDialogueAPI` after `createSession`: ```ts async getLatestSession(configId: string, userId: string): Promise { const params = new URLSearchParams({ configId, userId }) const res = await fetch(`${API_BASE}/sessions/latest?${params.toString()}`, { method: 'GET', credentials: 'include', }) if (!res.ok) { throw new DialogueApiError(`getLatestSession failed: ${res.status}`, res.status) } return res.json() } ``` - [ ] **Step 3: Seed engine from historical messages** In `src/views/Editor/EnglishSpeaking/composables/useDialogueEngine.ts`, update imports: ```ts import type { PreviewChatMessage, DialogueAPI, DialogueReport, HistoricalDialogueMessage } from '@/types/englishSpeaking' ``` Add helper near the session attach section: ```ts function toPreviewMessage(message: HistoricalDialogueMessage): PreviewChatMessage { return { id: message.id, role: message.role, content: message.content, timestamp: new Date(), status: 'done', turnId: message.clientTurnId ?? undefined, audioUrl: message.audioUrl ?? undefined, } } ``` Update `attachSession`: ```ts function attachSession(info: { sessionId: string expiresAt?: string | null totalRounds: number currentRound?: number messages?: HistoricalDialogueMessage[] }) { sessionId.value = info.sessionId expiresAt.value = info.expiresAt ?? null totalRounds.value = info.totalRounds currentRound.value = info.currentRound ?? 1 if (info.messages?.length) { messages.value = info.messages.map(toPreviewMessage) } if (info.expiresAt) startCountdown(info.expiresAt) } ``` - [ ] **Step 4: Pass history into chat view and skip greeting when restored** In `src/views/Editor/EnglishSpeaking/preview/DialogueChatView.vue`, update the `onMounted` block: ```ts onMounted(() => { if (props.sessionInfo) { const hasHistory = !!props.sessionInfo.messages?.length engine.attachSession({ sessionId: props.sessionInfo.sessionId, expiresAt: props.sessionInfo.expiresAt, totalRounds: props.totalRounds, currentRound: props.sessionInfo.currentRound, messages: props.sessionInfo.messages, }) if (!hasHistory) engine.generateGreeting() } else { console.warn('[DialogueChatView] mounted without sessionInfo; chat is inert. Parent must createSession before mounting.') } // 无 sessionInfo 时聊天区保持空(父组件应当先创建 session 再挂载本组件) }) ``` - [ ] **Step 5: Run frontend typecheck** Run: ```bash pnpm vue-tsc --noEmit ``` Expected: pass. If the repo does not define `vue-tsc`, run: ```bash pnpm tsc --noEmit ``` Expected: pass. - [ ] **Step 6: Commit frontend API/engine changes** Run: ```bash git add src/types/englishSpeaking.ts src/views/Editor/EnglishSpeaking/services/llmService.ts src/views/Editor/EnglishSpeaking/composables/useDialogueEngine.ts src/views/Editor/EnglishSpeaking/preview/DialogueChatView.vue git commit -m "feat: seed speaking chat from historical session" ``` --- ## Task 4: Frontend Student History Flow **Files:** - Modify: `/Users/buoy/Development/gitrepo/PPT/src/views/Editor/EnglishSpeaking/preview/TopicDiscussionPreview.vue` - [ ] **Step 1: Add runtime context helpers** In `TopicDiscussionPreview.vue`, after `preparedSession`, add: ```ts const historyChecked = ref(false) const runtimeParams = computed(() => { const params = new URLSearchParams(window.location.search) return { mode: params.get('mode'), userId: params.get('userid'), } }) const isStudentRuntime = computed(() => runtimeParams.value.mode === 'student') const runtimeUserId = computed(() => runtimeParams.value.userId || '') ``` - [ ] **Step 2: Add history loader** Add this function after `startDialogue`: ```ts async function loadLatestStudentSession() { if (historyChecked.value) return historyChecked.value = true if (!isStudentRuntime.value) return if (!props.configId) { sessionError.value = '口语工具配置缺失,请联系老师重新发布。' return } if (!runtimeUserId.value) { sessionError.value = '学生身份缺失,请从课程入口重新进入。' return } dialogueState.value = 'checking-history' sessionError.value = null try { const api = createDialogueApi() const { session } = await api.getLatestSession(props.configId, runtimeUserId.value) if (!session) { dialogueState.value = 'ready' return } preparedSession.value = { sessionId: session.sessionId, expiresAt: session.expiresAt, currentRound: session.currentRound, messages: session.messages, } if (session.status === 'completed') { const report = await api.getReport(session.sessionId) handleDialogueComplete(report) return } dialogueState.value = 'chatting' } catch (err: unknown) { console.error('[speaking] load latest session failed:', err) dialogueState.value = 'ready' if (err instanceof DialogueApiError) { sessionError.value = `读取历史会话失败(${err.status}),请刷新重试` } else { sessionError.value = '读取历史会话失败,请刷新重试' } } } ``` - [ ] **Step 3: Pass identity when creating sessions** In `startDialogue`, add `configId` and `userId` to the `api.createSession` payload: ```ts const info = await api.createSession({ topic: speakingStore.config.topic || props.topic, grade: speakingStore.config.grade, totalRounds: speakingStore.config.practice.rounds ?? props.totalRounds, durationMinutes: speakingStore.config.practice.duration, roleId: mockRole.id, vocabulary: speakingStore.config.learningGoals.vocabulary, sentences: speakingStore.config.learningGoals.sentences, configId: props.configId || null, userId: isStudentRuntime.value ? runtimeUserId.value : null, }) ``` Update `preparedSession.value`: ```ts preparedSession.value = { sessionId: info.sessionId, expiresAt: info.expiresAt, currentRound: info.currentRound, } ``` - [ ] **Step 4: Render checking state** In the template, above the ready-stage block, add: ```vue

💬 Topic Discussion

正在读取你的练习记录...

``` Then change the existing ready block from: ```vue
``` to: ```vue
``` - [ ] **Step 5: Trigger history lookup after config load** Update `loadConfigFromBackend`: ```ts async function loadConfigFromBackend(id: string) { if (!id) { await loadLatestStudentSession() return } try { const { config } = await getSpeakingConfig(id) speakingStore.$patch({ config }) } catch (err) { console.error('[speaking] load config failed:', err) } finally { await loadLatestStudentSession() } } ``` Update the `props.configId` watcher so a new config can reset the history check: ```ts watch(() => props.configId, (id) => { historyChecked.value = false loadConfigFromBackend(id) }) ``` - [ ] **Step 6: Run frontend typecheck** Run: ```bash pnpm vue-tsc --noEmit ``` Expected: pass. If unavailable, run: ```bash pnpm tsc --noEmit ``` Expected: pass. - [ ] **Step 7: Commit student history flow** Run: ```bash git add src/views/Editor/EnglishSpeaking/preview/TopicDiscussionPreview.vue git commit -m "feat: restore student speaking sessions" ``` --- ## Task 5: End-to-End Verification **Files:** - Verify backend repository: `/Users/buoy/Development/gitrepo/cococlass-english-speaking-api` - Verify frontend repository: `/Users/buoy/Development/gitrepo/PPT` - [ ] **Step 1: Run backend tests** Run in backend repo: ```bash uv run pytest tests/api/test_dialogue_greeting.py tests/api/test_dialogue_task_hint.py tests/service/speaking/test_dialogue_service_greeting.py tests/service/speaking/test_dialogue_service_report.py -q ``` Expected: pass. - [ ] **Step 2: Run frontend typecheck** Run in frontend repo: ```bash pnpm vue-tsc --noEmit ``` Expected: pass. If unavailable, run: ```bash pnpm tsc --noEmit ``` Expected: pass. - [ ] **Step 3: Manual API smoke test for latest lookup** With the backend running, create a session: ```bash curl -s -X POST http://localhost:8000/api/speaking/dialogue/session \ -H 'Content-Type: application/json' \ -d '{"topic":"Smoke","grade":"grade5-1","totalRounds":2,"configId":"smoke-config","userId":"smoke-user"}' ``` Expected JSON includes `"sessionId"`. Then query latest: ```bash curl -s 'http://localhost:8000/api/speaking/dialogue/sessions/latest?configId=smoke-config&userId=smoke-user' ``` Expected JSON: ```json { "session": { "sessionId": "...", "status": "active", "totalRounds": 2, "currentRound": 1, "expiresAt": null, "createdAt": "...", "completedAt": null, "messages": [] } } ``` - [ ] **Step 4: Manual frontend runtime checks** Use browser URLs that match existing app behavior: ```text /?mode=editor3&courseid=smoke-course&userid=smoke-user ``` Expected: preview does not call `/sessions/latest`; start creates a new preview session only when clicked. Use a real course id in place of `smoke-course` when running against non-seeded data. ```text /?mode=student&courseid=smoke-course&userid=smoke-user ``` Expected: formal student mode calls `/sessions/latest?configId=&userId=smoke-user` before showing the start button. If the endpoint returns a session, the first-time start button is skipped. - [ ] **Step 5: Commit verification notes if any test fixtures changed** If verification required code or fixture changes, commit them: ```bash git status --short git add tests/api/test_dialogue_greeting.py src/views/Editor/EnglishSpeaking/preview/TopicDiscussionPreview.vue git commit -m "test: verify student speaking session history" ``` If no files changed, do not create an empty commit. --- ## Self-Review Spec coverage: - `mode=student` versus `mode=editor3` runtime behavior is covered in Task 4 and Task 5. - `config_id` persistence and migration are covered in Task 1. - latest lookup with message history is covered in Task 2. - frontend `configId + userId` create and history lookup are covered in Tasks 3 and 4. - active history message restoration is covered in Task 3. - completed history routing to report is covered in Task 4. - "practice again" is intentionally out of scope and documented as not implemented. Placeholder scan: - The plan contains no placeholder markers or undefined placeholder steps. Type consistency: - Backend API field names use `configId` and `userId`. - Database and ORM field names use `config_id` and `user_id`. - Frontend reads URL `userid` and maps it to backend `userId`. - latest route uses `/api/speaking/dialogue/sessions/latest` consistently.