# 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<SessionInfo>
  getLatestSession(configId: string, userId: string): Promise<LatestSessionResponse>
  completeSession(sessionId: string): Promise<void>
  /** Throws DOMException('AbortError') on signal abort; throws DialogueApiError on non-OK HTTP. */
  generateGreeting(sessionId: string, turnId: string, signal?: AbortSignal): Promise<GreetingInfo>
  generateTaskHint(sessionId: string): Promise<TaskHint>
  speak(sessionId: string, audioBlob: Blob, signal: AbortSignal, turnId: string): AsyncGenerator<SSEEvent>
  getReport(sessionId: string): Promise<DialogueReport>
}
```

- [ ] **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<LatestSessionResponse> {
    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
    <div v-if="dialogueState === 'checking-history'" class="ready-stage">
      <div class="ready-header">
        <h1 class="ready-title">
          <span class="ready-title-icon">💬</span>
          Topic Discussion
        </h1>
        <p class="ready-subtitle">正在读取你的练习记录...</p>
      </div>
      <div class="ready-body">
        <span class="start-btn-spinner" />
      </div>
    </div>
```

Then change the existing ready block from:

```vue
    <div v-if="dialogueState === 'ready'" class="ready-stage">
```

to:

```vue
    <div v-else-if="dialogueState === 'ready'" class="ready-stage">
```

- [ ] **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=<the type 77 frame url value>&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.
