# Dialogue Recording Resilience & Idempotency 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:** Fix six interlocking gaps in `DialogueChatView`'s recording flow — loading state on 开始录音, no-op cancel, late placeholder push, recovery-aware error buttons, `client_turn_id` idempotency on `dialogue_message`, and skipping LLM on the final student turn.

**Architecture:** Backend gains a `client_turn_id` column on `dialogue_message` plus a three-branch lookup in `speak()` (full miss / partial hit / full hit) and a final-round skip-LLM branch. Frontend's `useDialogueEngine` is restructured: `beginStudentStream` returns `{turnId, pushChunk, commit, abort}` where `commit` is what pushes message placeholders (so 取消 needs no cleanup); errors are classified to `retry | rerecord | restart` and rendered as recovery-aware buttons. `DialogueChatView` adds `starting` and `finalizing` states with cancel affordance during mic acquisition and a transition card during report fetch.

**Tech Stack:** Backend — Python 3, FastAPI, SQLAlchemy async, MySQL (SQLite for tests), pytest + pytest-asyncio. Frontend — Vue 3 `<script setup>` + TypeScript, Vite, native WebSocket / fetch / `AbortController`.

---

## File Structure

Two repos involved. Repo roots:
- Frontend: `/Users/buoy/Development/gitrepo/PPT`
- Backend: `/Users/buoy/Development/gitrepo/cococlass-english-speaking-api`

### Backend changes

- Create: `migrations/004_add_client_turn_id.sql` — schema migration adding the column + UNIQUE index.
- Modify: `app/models/dialogue.py` — add `client_turn_id: Mapped[str | None]` to `DialogueMessage`.
- Modify: `app/service/speaking/dialogue_service.py` — add `_lookup_turn_state()` + `_replay_turn()` helpers; refactor `speak()` to branch on turn state and skip LLM on the final round; thread `client_turn_id` through.
- Modify: `app/api/dialogue.py` — accept `turnId` in `POST /speak` Form, `POST /session/{id}/greeting` body, and WS `start` frame.
- Modify: `tests/conftest.py` — extend in-memory DB schema (already created via `Base.metadata.create_all` so the new column is automatic; no change needed in conftest itself, but new tests reference it).
- Create: `tests/service/speaking/test_dialogue_service_idempotency.py` — TDD tests for the three branches + final-round skip + concurrency.
- Modify: `tests/api/test_dialogue_greeting.py` — adapt existing tests if they break on the new required `turnId`.

### Frontend changes

- Modify: `src/types/englishSpeaking.ts` — add `turnId?: string` and `recovery?: 'retry' | 'rerecord' | 'restart'` to `PreviewChatMessage`; remove `unrecoverable?: boolean` if unused after refactor.
- Modify: `src/views/Editor/EnglishSpeaking/services/llmService.ts` — `speak()` and `generateGreeting()` accept `turnId` and include it in the request body.
- Modify: `src/views/Editor/EnglishSpeaking/composables/useDialogueEngine.ts` — biggest change: `attachSession` takes `totalRounds`; `classifyError()` helper; `beginStudentStream` returns the new `{turnId, pushChunk, commit, abort}` shape; placeholders pushed at commit; final-round skips aiMsg push; `discardCurrentTurn`; `retryMessage` / `regenerateAiMessage` pass `turnId`; greeting generates a `turnId` too.
- Modify: `src/views/Editor/EnglishSpeaking/composables/useAudioRecorder.ts` — `startRecording(signal?: AbortSignal)` plumbs through to `getUserMedia`.
- Modify: `src/views/Editor/EnglishSpeaking/preview/DialogueChatView.vue` — `state` computed adds `starting` / `finalizing`; new `isStarting` and `reportFetchInflight` refs; new `state-starting` / `state-finalizing` layers; `handleStartRecording` opens an `AbortController`; `handleCancelRecording` uses new no-op semantics; error card renders recovery-aware buttons; new `handleRetry` / `handleRerecord` handlers; greeting passes a `turnId`.

Spec reference: `docs/superpowers/specs/2026-04-26-dialogue-recording-resilience-design.md`

Package managers: backend uses `uv`; frontend uses `npm`.

---

## Implementation order

Backend ships first (so the frontend can integrate against a live, idempotency-aware backend during dev), then frontend. Within backend, tests drive each new branch.

---

### Task 1: Schema migration

Adds the column and the UNIQUE index that the entire idempotency story rests on. Standalone, reversible.

**Files:**
- Create: `/Users/buoy/Development/gitrepo/cococlass-english-speaking-api/migrations/004_add_client_turn_id.sql`
- Modify: `/Users/buoy/Development/gitrepo/cococlass-english-speaking-api/app/models/dialogue.py`

- [ ] **Step 1: Write the migration SQL**

Create `migrations/004_add_client_turn_id.sql`:

```sql
-- 004_add_client_turn_id.sql
-- Adds idempotency key column to dialogue_message, scoped per-session.
-- See docs/superpowers/specs/2026-04-26-dialogue-recording-resilience-design.md (D5).
ALTER TABLE dialogue_message
    ADD COLUMN client_turn_id VARCHAR(36) NULL;

CREATE UNIQUE INDEX uk_session_turn_role
    ON dialogue_message (session_id, client_turn_id, role);
```

- [ ] **Step 2: Add the column to the SQLAlchemy model**

Open `app/models/dialogue.py` and locate the `DialogueMessage` class. Add the new field next to `audio_url`:

```python
client_turn_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
```

(Use the same `Mapped` / `mapped_column` style as existing fields. Import `String` from `sqlalchemy` if not already imported.)

- [ ] **Step 3: Verify the test SQLite schema picks up the new column**

The test fixture in `tests/api/test_dialogue_greeting.py:test_env` calls `Base.metadata.create_all`, which reflects the model change automatically. Run the existing greeting test suite to confirm nothing broke:

```bash
cd /Users/buoy/Development/gitrepo/cococlass-english-speaking-api
uv run pytest tests/api/test_dialogue_greeting.py -v
```

Expected: same green/red as before this task (no new failures from the column add).

- [ ] **Step 4: Commit**

```bash
cd /Users/buoy/Development/gitrepo/cococlass-english-speaking-api
git add migrations/004_add_client_turn_id.sql app/models/dialogue.py
git commit -m "feat(speaking): add client_turn_id column to dialogue_message

Schema-only change; nothing reads or writes it yet. Subsequent commits
add the lookup helper and refactor speak() to use it."
```

---

### Task 2: Turn-state lookup helper (TDD)

Pure function: given a `(session_id, client_turn_id)`, return one of `'miss' | 'partial' | 'full'` plus the existing rows. Used by `speak()` to decide which branch to take.

**Files:**
- Modify: `/Users/buoy/Development/gitrepo/cococlass-english-speaking-api/app/service/speaking/dialogue_service.py` — add `_lookup_turn_state` method on `DialogueService`.
- Create: `/Users/buoy/Development/gitrepo/cococlass-english-speaking-api/tests/service/speaking/test_dialogue_service_idempotency.py`

- [ ] **Step 1: Write the failing test for the three states**

Create `tests/service/speaking/test_dialogue_service_idempotency.py`:

```python
"""Tests for client_turn_id-based idempotency in DialogueService.speak."""
from datetime import datetime
from unittest.mock import AsyncMock, MagicMock

import pytest
import pytest_asyncio
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from sqlalchemy.pool import StaticPool

from app.models.dialogue import Base, DialogueMessage, DialogueSession
from app.service.speaking.dialogue_service import DialogueService


@pytest_asyncio.fixture
async def db_session():
    engine = create_async_engine(
        "sqlite+aiosqlite:///:memory:",
        poolclass=StaticPool,
        connect_args={"check_same_thread": False},
    )
    async with engine.begin() as conn:
        await conn.run_sync(Base.metadata.create_all)
    SessionLocal = async_sessionmaker(engine, expire_on_commit=False)
    async with SessionLocal() as s:
        yield s


@pytest.fixture
def service():
    return DialogueService(asr=MagicMock(), llm=MagicMock(), assessor=MagicMock(), storage=MagicMock())


@pytest_asyncio.fixture
async def fixture_session(db_session):
    s = DialogueSession(
        uuid="sess-1",
        topic="t",
        total_rounds=3,
        current_round=1,
        status="active",
        system_prompt="p",
        created_at=datetime.now(),
    )
    db_session.add(s)
    await db_session.commit()
    return s


@pytest.mark.asyncio
async def test_lookup_turn_state_miss(service, db_session, fixture_session):
    state, rows = await service._lookup_turn_state(db_session, fixture_session.id, "T1")
    assert state == "miss"
    assert rows == []


@pytest.mark.asyncio
async def test_lookup_turn_state_partial(service, db_session, fixture_session):
    db_session.add(DialogueMessage(
        session_id=fixture_session.id, round=1, role="student",
        content="hi", client_turn_id="T1",
    ))
    await db_session.commit()
    state, rows = await service._lookup_turn_state(db_session, fixture_session.id, "T1")
    assert state == "partial"
    assert len(rows) == 1
    assert rows[0].role == "student"


@pytest.mark.asyncio
async def test_lookup_turn_state_full(service, db_session, fixture_session):
    db_session.add(DialogueMessage(
        session_id=fixture_session.id, round=1, role="student",
        content="hi", client_turn_id="T1",
    ))
    db_session.add(DialogueMessage(
        session_id=fixture_session.id, round=1, role="ai",
        content="hello", client_turn_id="T1",
    ))
    await db_session.commit()
    state, rows = await service._lookup_turn_state(db_session, fixture_session.id, "T1")
    assert state == "full"
    assert len(rows) == 2
    assert {r.role for r in rows} == {"student", "ai"}
```

- [ ] **Step 2: Run the tests to confirm they fail**

```bash
cd /Users/buoy/Development/gitrepo/cococlass-english-speaking-api
uv run pytest tests/service/speaking/test_dialogue_service_idempotency.py -v
```

Expected: FAIL — `AttributeError: 'DialogueService' object has no attribute '_lookup_turn_state'`.

- [ ] **Step 3: Implement `_lookup_turn_state`**

In `app/service/speaking/dialogue_service.py`, add (place it right after `__init__` or anywhere on the `DialogueService` class):

```python
async def _lookup_turn_state(
    self,
    db: AsyncSession,
    session_id: int,
    client_turn_id: str,
) -> tuple[str, list[DialogueMessage]]:
    """Returns ('miss'|'partial'|'full', rows) for the given turn key."""
    result = await db.execute(
        select(DialogueMessage)
        .where(DialogueMessage.session_id == session_id)
        .where(DialogueMessage.client_turn_id == client_turn_id)
        .order_by(DialogueMessage.id)
    )
    rows = list(result.scalars().all())
    if not rows:
        return ("miss", [])
    has_student = any(r.role == "student" for r in rows)
    has_ai = any(r.role == "ai" for r in rows)
    if has_student and has_ai:
        return ("full", rows)
    if has_student:
        return ("partial", rows)
    # Edge: somehow we have only an ai row without student — treat as full miss
    # (this shouldn't happen with the insert order, but be defensive)
    return ("miss", rows)
```

- [ ] **Step 4: Run the tests; expect pass**

```bash
uv run pytest tests/service/speaking/test_dialogue_service_idempotency.py -v
```

Expected: 3 passed.

- [ ] **Step 5: Commit**

```bash
git add app/service/speaking/dialogue_service.py tests/service/speaking/test_dialogue_service_idempotency.py
git commit -m "feat(speaking): add _lookup_turn_state helper for idempotency

Pure async query keyed on (session_id, client_turn_id). Returns one of
'miss' | 'partial' | 'full'. Used in subsequent commit to branch speak()."
```

---

### Task 3: Replay helper for full-hit branch (TDD)

When `_lookup_turn_state` returns `'full'`, we need to yield the same event sequence (`transcript`, `token` × N as one chunk, `done`) without calling STT/LLM/round-bumping.

**Files:**
- Modify: `app/service/speaking/dialogue_service.py` — add `_replay_full_turn` async generator.
- Modify: `tests/service/speaking/test_dialogue_service_idempotency.py` — add replay test.

- [ ] **Step 1: Write the failing test**

Append to `tests/service/speaking/test_dialogue_service_idempotency.py`:

```python
@pytest.mark.asyncio
async def test_replay_full_turn(service, db_session, fixture_session):
    db_session.add(DialogueMessage(
        session_id=fixture_session.id, round=1, role="student",
        content="cats are cute", client_turn_id="T1",
    ))
    db_session.add(DialogueMessage(
        session_id=fixture_session.id, round=1, role="ai",
        content="I love cats too!", client_turn_id="T1",
    ))
    await db_session.commit()

    state, rows = await service._lookup_turn_state(db_session, fixture_session.id, "T1")
    assert state == "full"

    events = []
    async for ev in service._replay_full_turn(rows, total_rounds=3, next_round=2):
        events.append(ev)

    assert events[0] == ("transcript", {"text": "cats are cute", "round": 1})
    assert events[1] == ("token", {"content": "I love cats too!"})
    assert events[2] == ("done", {"isComplete": False, "nextRound": 2})
```

- [ ] **Step 2: Run; expect FAIL** (`_replay_full_turn` undefined).

```bash
uv run pytest tests/service/speaking/test_dialogue_service_idempotency.py::test_replay_full_turn -v
```

- [ ] **Step 3: Implement `_replay_full_turn`**

Add to `DialogueService`:

```python
async def _replay_full_turn(
    self,
    rows: list[DialogueMessage],
    total_rounds: int,
    next_round: int,
) -> AsyncIterator[tuple[str, dict]]:
    """Yield transcript / token / done from previously-stored rows.

    No DB writes, no STT/LLM, no round bump. Used when the same turnId
    has been fully processed before (network drop after server commit).
    """
    student = next(r for r in rows if r.role == "student")
    ai = next(r for r in rows if r.role == "ai")
    yield ("transcript", {"text": student.content, "round": student.round})
    # Single chunk — see spec out-of-scope: streaming fidelity not required on replay.
    yield ("token", {"content": ai.content})
    is_complete = next_round > total_rounds
    yield ("done", {"isComplete": is_complete, "nextRound": next_round})
```

- [ ] **Step 4: Run; expect PASS**

```bash
uv run pytest tests/service/speaking/test_dialogue_service_idempotency.py::test_replay_full_turn -v
```

- [ ] **Step 5: Commit**

```bash
git add app/service/speaking/dialogue_service.py tests/service/speaking/test_dialogue_service_idempotency.py
git commit -m "feat(speaking): add _replay_full_turn for cached idempotent retries

Yields transcript / token / done from existing DialogueMessage rows
without re-running STT or LLM. Single token chunk is acceptable per
spec (replay fidelity is out of scope)."
```

---

### Task 4: Wire idempotency into `speak()` (TDD)

This is the big refactor: `speak()` accepts `client_turn_id`, calls `_lookup_turn_state`, and branches.

**Files:**
- Modify: `app/service/speaking/dialogue_service.py` — `speak()` signature + body.
- Modify: `tests/service/speaking/test_dialogue_service_idempotency.py` — full-hit + partial-hit + miss tests on the integrated flow.

- [ ] **Step 1: Write failing tests for the three branches end-to-end**

Append to `tests/service/speaking/test_dialogue_service_idempotency.py`:

```python
@pytest_asyncio.fixture
def service_with_mocks():
    asr = MagicMock()
    asr.transcribe = AsyncMock(return_value="hello world")
    llm = MagicMock()

    async def fake_stream(*_a, **_kw):
        for tok in ["hi ", "there", "!"]:
            yield tok
    llm.chat_stream = fake_stream

    storage = MagicMock()
    storage.upload = AsyncMock(return_value="s3://bucket/audio.webm")

    return DialogueService(asr=asr, llm=llm, assessor=MagicMock(), storage=storage)


@pytest.mark.asyncio
async def test_speak_full_hit_skips_stt_and_llm(service_with_mocks, db_session, fixture_session):
    # Pre-seed the turn as fully completed
    db_session.add(DialogueMessage(
        session_id=fixture_session.id, round=1, role="student",
        content="cached transcript", client_turn_id="T1",
    ))
    db_session.add(DialogueMessage(
        session_id=fixture_session.id, round=1, role="ai",
        content="cached reply", client_turn_id="T1",
    ))
    await db_session.commit()
    initial_round = fixture_session.current_round

    events = []
    async for ev in service_with_mocks.speak(
        db=db_session, session=fixture_session,
        audio_bytes=b"x", content_type="audio/webm",
        client_turn_id="T1",
    ):
        events.append(ev)

    # No STT, no LLM, no round bump
    service_with_mocks.asr.transcribe.assert_not_called()
    service_with_mocks.storage.upload.assert_not_called()
    await db_session.refresh(fixture_session)
    assert fixture_session.current_round == initial_round

    # Cached content replayed
    assert events[0] == ("transcript", {"text": "cached transcript", "round": 1})
    assert events[1] == ("token", {"content": "cached reply"})
    assert events[-1][0] == "done"


@pytest.mark.asyncio
async def test_speak_partial_hit_skips_stt_runs_llm(service_with_mocks, db_session, fixture_session):
    # Only student row exists (LLM crashed on prior attempt)
    db_session.add(DialogueMessage(
        session_id=fixture_session.id, round=1, role="student",
        content="cached transcript", client_turn_id="T1",
    ))
    await db_session.commit()

    events = []
    async for ev in service_with_mocks.speak(
        db=db_session, session=fixture_session,
        audio_bytes=b"x", content_type="audio/webm",
        client_turn_id="T1",
    ):
        events.append(ev)

    # STT skipped; LLM ran
    service_with_mocks.asr.transcribe.assert_not_called()
    # Transcript came from DB
    assert events[0] == ("transcript", {"text": "cached transcript", "round": 1})
    # Tokens streamed
    token_events = [e for e in events if e[0] == "token"]
    assert len(token_events) == 3
    # Done with round bumped
    done = events[-1]
    assert done[0] == "done"


@pytest.mark.asyncio
async def test_speak_full_miss_runs_full_pipeline(service_with_mocks, db_session, fixture_session):
    events = []
    async for ev in service_with_mocks.speak(
        db=db_session, session=fixture_session,
        audio_bytes=b"x", content_type="audio/webm",
        client_turn_id="T1",
    ):
        events.append(ev)

    service_with_mocks.asr.transcribe.assert_called_once()
    # transcript + 3 tokens + done = 5
    assert len([e for e in events if e[0] == "token"]) == 3
    # client_turn_id propagated to inserted rows
    from sqlalchemy import select as sa_select
    res = await db_session.execute(
        sa_select(DialogueMessage).where(DialogueMessage.client_turn_id == "T1")
    )
    rows = list(res.scalars().all())
    assert len(rows) == 2
    assert {r.role for r in rows} == {"student", "ai"}
```

- [ ] **Step 2: Run; expect FAIL** (signature mismatch — `speak()` doesn't take `client_turn_id` yet).

```bash
uv run pytest tests/service/speaking/test_dialogue_service_idempotency.py -v
```

- [ ] **Step 3: Refactor `speak()` to branch on turn state**

In `app/service/speaking/dialogue_service.py`, replace the existing `speak` method (around line 338–455). The full new body:

```python
async def speak(
    self,
    db: AsyncSession,
    session: DialogueSession,
    audio_bytes: bytes,
    content_type: str = "audio/webm;codecs=opus",
    client_turn_id: str | None = None,
) -> AsyncIterator[tuple[str, dict]]:
    """录音 → 转录 → LLM 流式回复 → 后台评估。

    With client_turn_id: idempotent. Repeated calls with the same key
    return the cached result without re-running STT/LLM/round-bump.
    """
    current_round = session.current_round

    # ① 检查是否已超时
    if session.expires_at and datetime.now() > session.expires_at:
        session.status = "completed"
        session.completed_at = datetime.now()
        await db.commit()
        yield ("done", {"isComplete": True, "nextRound": session.current_round})
        return

    # ② Idempotency lookup (only if client_turn_id provided)
    existing_rows: list[DialogueMessage] = []
    turn_state = "miss"
    if client_turn_id:
        turn_state, existing_rows = await self._lookup_turn_state(
            db, session.id, client_turn_id
        )

    # ③ Full hit: replay from DB, no side effects
    if turn_state == "full":
        # next_round is already in DB state — current_round was bumped on first pass
        async for ev in self._replay_full_turn(
            existing_rows,
            total_rounds=session.total_rounds,
            next_round=session.current_round,
        ):
            yield ev
        return

    try:
        if turn_state == "partial":
            # ④ Partial hit: reuse stored transcript, skip STT
            student_row = next(r for r in existing_rows if r.role == "student")
            transcript = student_row.content
            student_msg = student_row
            yield ("transcript", {"text": transcript, "round": student_row.round})
        else:
            # ④ Full miss: run STT + insert student row
            filename = f"{session.uuid}/round_{current_round}.webm"
            logger.info(f"ASR start: session={session.uuid}, round={current_round}, turn={client_turn_id}")
            audio_url, transcript = await asyncio.gather(
                self.storage.upload(audio_bytes, filename),
                self.asr.transcribe(audio_bytes, content_type),
            )
            logger.info(f"ASR done: '{transcript[:50]}...'")

            yield ("transcript", {"text": transcript, "round": current_round})

            student_msg = DialogueMessage(
                session_id=session.id,
                round=current_round,
                role="student",
                content=transcript,
                audio_url=audio_url,
                client_turn_id=client_turn_id,
            )
            db.add(student_msg)
            await db.flush()

            # ⑤ 创建评估记录
            evaluation = PronunciationEvaluation(
                message_id=student_msg.id,
                session_id=session.id,
                round=current_round,
                status="pending",
            )
            db.add(evaluation)
            await db.flush()

        # ⑥ 构建 LLM prompt
        history_result = await db.execute(
            select(DialogueMessage)
            .where(DialogueMessage.session_id == session.id)
            .order_by(DialogueMessage.created_at)
        )
        history = list(history_result.scalars().all())

        prior_ai_turn = ""
        for msg in reversed(history):
            if msg.role == "ai":
                prior_ai_turn = msg.content
                break

        llm_messages = [{"role": "system", "content": session.system_prompt}]
        for msg in history:
            role = "assistant" if msg.role == "ai" else "user"
            llm_messages.append({"role": role, "content": msg.content})

        # ⑦ LLM 流式
        logger.info(f"LLM stream start: {len(llm_messages)} messages")
        full_response = ""
        async for token in self.llm.chat_stream(llm_messages, model=""):
            full_response += token
            yield ("token", {"content": token})

        # ⑧ 写入 AI 消息
        ai_msg = DialogueMessage(
            session_id=session.id,
            round=_ai_reply_round(current_round, session.total_rounds),
            role="ai",
            content=full_response,
            client_turn_id=client_turn_id,
        )
        db.add(ai_msg)

        # ⑨ 推进轮次
        session.current_round = current_round + 1
        is_complete = session.current_round > session.total_rounds
        if not is_complete and session.expires_at:
            is_complete = datetime.now() > session.expires_at
        if is_complete:
            session.status = "completed"
            session.completed_at = datetime.now()

        await db.commit()

        # ⑩ 后台评估 — only on full miss (partial hit's evaluation was already enqueued)
        if turn_state == "miss":
            logger.info(f"Launching background pronunciation assessment: msg={student_msg.id}")
            asyncio.create_task(
                self._evaluate_pronunciation(
                    evaluation_id=evaluation.id,
                    audio_bytes=audio_bytes,
                    reference_text=transcript,
                    prior_ai_turn=prior_ai_turn,
                    content_type=content_type,
                )
            )

        # ⑪ done
        yield ("done", {"isComplete": is_complete, "nextRound": session.current_round})

    except IntegrityError:
        # Concurrent retry beat us to an INSERT. Roll back, fall through to lookup again.
        await db.rollback()
        logger.warning(f"IntegrityError on speak (concurrent retry?): session={session.uuid}, turn={client_turn_id}")
        if client_turn_id:
            _, rows = await self._lookup_turn_state(db, session.id, client_turn_id)
            async for ev in self._replay_full_turn(
                rows,
                total_rounds=session.total_rounds,
                next_round=session.current_round,
            ):
                yield ev
            return
        yield ("error", {"message": "Internal server error"})
    except Exception as e:
        logger.error(f"Speak failed: session={session.uuid}, round={current_round}, turn={client_turn_id}, error={e}")
        await db.rollback()
        yield ("error", {"message": "Internal server error"})
```

Add the `IntegrityError` import at the top of the file:

```python
from sqlalchemy.exc import IntegrityError
```

- [ ] **Step 4: Run the new tests; expect PASS**

```bash
uv run pytest tests/service/speaking/test_dialogue_service_idempotency.py -v
```

- [ ] **Step 5: Run the full backend test suite to confirm no regressions**

```bash
uv run pytest -v
```

Expected: all green except possibly tests in `test_dialogue_service_content.py` / `test_dialogue_service_greeting.py` if they call `speak()` without `client_turn_id`. Those are fine — `client_turn_id` is optional (defaults to `None`, which means no idempotency lookup).

- [ ] **Step 6: Commit**

```bash
git add app/service/speaking/dialogue_service.py tests/service/speaking/test_dialogue_service_idempotency.py
git commit -m "feat(speaking): add client_turn_id idempotency to speak()

Three-branch lookup (full miss / partial hit / full hit) keyed on
(session_id, client_turn_id). Full hit replays from DB without
STT/LLM/round-bump. Partial hit reuses stored transcript and runs
only the LLM. IntegrityError on concurrent retry rolls back and
falls through to replay."
```

---

### Task 5: Skip LLM on the final student turn (TDD)

When `current_round >= total_rounds` and we're in the full-miss branch, write the student row, yield transcript, mark session complete, yield done — no LLM call, no AI row.

**Files:**
- Modify: `app/service/speaking/dialogue_service.py` — final-round early return.
- Modify: `tests/service/speaking/test_dialogue_service_idempotency.py` — add the test.

- [ ] **Step 1: Write the failing test**

Append to `tests/service/speaking/test_dialogue_service_idempotency.py`:

```python
@pytest.mark.asyncio
async def test_speak_final_round_skips_llm(service_with_mocks, db_session, fixture_session):
    # Set the session to its last round
    fixture_session.current_round = fixture_session.total_rounds  # 3
    await db_session.commit()

    events = []
    async for ev in service_with_mocks.speak(
        db=db_session, session=fixture_session,
        audio_bytes=b"x", content_type="audio/webm",
        client_turn_id="T-final",
    ):
        events.append(ev)

    # STT ran (we still need transcript)
    service_with_mocks.asr.transcribe.assert_called_once()
    # LLM did NOT run
    assert all(e[0] != "token" for e in events), f"unexpected token events: {events}"

    # done with isComplete=True
    done = events[-1]
    assert done[0] == "done"
    assert done[1]["isComplete"] is True

    # Only student row inserted
    from sqlalchemy import select as sa_select
    res = await db_session.execute(
        sa_select(DialogueMessage).where(DialogueMessage.client_turn_id == "T-final")
    )
    rows = list(res.scalars().all())
    assert len(rows) == 1
    assert rows[0].role == "student"
```

- [ ] **Step 2: Run; expect FAIL** (LLM still runs).

```bash
uv run pytest tests/service/speaking/test_dialogue_service_idempotency.py::test_speak_final_round_skips_llm -v
```

- [ ] **Step 3: Add the early-return branch in `speak()`**

In `dialogue_service.py`, after step ⑤ (evaluation insert) and before step ⑥ (LLM prompt build), add:

```python
        # ⑤b 末轮跳过 LLM:学生说完最后一句 → 直接 done(isComplete=True),不再产 AI 回复
        if current_round >= session.total_rounds:
            session.current_round = current_round + 1
            session.status = "completed"
            session.completed_at = datetime.now()
            await db.commit()

            if turn_state == "miss":
                logger.info(f"Launching background pronunciation assessment: msg={student_msg.id}")
                asyncio.create_task(
                    self._evaluate_pronunciation(
                        evaluation_id=evaluation.id,
                        audio_bytes=audio_bytes,
                        reference_text=transcript,
                        prior_ai_turn="",  # last round: no prior AI to compare against
                        content_type=content_type,
                    )
                )

            yield ("done", {"isComplete": True, "nextRound": session.current_round})
            return
```

Note: `evaluation` only exists on the `miss` branch; the `if turn_state == "miss"` guard is critical. On the partial-hit branch (which can also hit "final round"), the evaluation was enqueued on the prior attempt.

- [ ] **Step 4: Run; expect PASS**

```bash
uv run pytest tests/service/speaking/test_dialogue_service_idempotency.py -v
```

All idempotency tests including the new `test_speak_final_round_skips_llm`.

- [ ] **Step 5: Run the full suite for regressions**

```bash
uv run pytest -v
```

- [ ] **Step 6: Commit**

```bash
git add app/service/speaking/dialogue_service.py tests/service/speaking/test_dialogue_service_idempotency.py
git commit -m "feat(speaking): skip LLM on the final student turn

Saves one LLM + one TTS per session and aligns the round indicator
with actual AI utterances (3/3 = 1 greeting + 3 replies). Final-round
done event carries isComplete=True; client transitions straight to the
report flow."
```

---

### Task 6: HTTP `/speak` endpoint accepts `turnId`

**Files:**
- Modify: `app/api/dialogue.py` — `speak()` route handler.

- [ ] **Step 1: Locate the speak route**

Around line 142–183 in `app/api/dialogue.py`. Current signature uses `Form(...)` for `sessionId`.

- [ ] **Step 2: Add `turnId` Form parameter and pass it through**

```python
@router.post("/speak")
async def speak(
    sessionId: str = Form(...),
    turnId: str = Form(...),  # NEW: required
    audio: UploadFile = File(...),
    db: AsyncSession = Depends(get_db),
    service: DialogueService = Depends(get_dialogue_service),
):
    # ... existing session lookup unchanged ...

    return StreamingResponse(
        _stream_events(
            service.speak(
                db=db,
                session=session,
                audio_bytes=audio_bytes,
                content_type=audio_content_type,
                client_turn_id=turnId,  # NEW
            )
        ),
        media_type="text/event-stream",
    )
```

The exact diff depends on the current code shape — keep all existing logic, only add the new param and forward it.

- [ ] **Step 3: Type-check / smoke run**

```bash
cd /Users/buoy/Development/gitrepo/cococlass-english-speaking-api
uv run ruff check app/api/dialogue.py
uv run pytest tests/api -v
```

If existing API tests for `/speak` exist, they may need updating to pass `turnId`. Update any failing tests by adding `data={"sessionId": "...", "turnId": "any-uuid"}` to the multipart request.

- [ ] **Step 4: Commit**

```bash
git add app/api/dialogue.py tests/api/
git commit -m "feat(speaking): require turnId on POST /speak"
```

---

### Task 7: HTTP `/session/{id}/greeting` accepts `turnId`

**Files:**
- Modify: `app/api/dialogue.py` — `generate_greeting()` route.
- Modify: `app/service/speaking/dialogue_service.py` — `generate_greeting()` method to accept and store `client_turn_id` on the AI message it creates.
- Modify: `tests/api/test_dialogue_greeting.py` — adapt existing tests.

- [ ] **Step 1: Add `turnId` to the request body model**

In `app/api/dialogue.py`, find the greeting route (around line 85–100). It likely takes the session id from path and may or may not have a body model. Add `turnId` as a body field via Pydantic or `Body()`:

```python
from pydantic import BaseModel

class GreetingRequest(BaseModel):
    turnId: str

@router.post("/session/{session_id}/greeting")
async def generate_greeting(
    session_id: str,
    body: GreetingRequest,
    db: AsyncSession = Depends(get_db),
    service: DialogueService = Depends(get_dialogue_service),
):
    logger.info(f"Generating greeting: session={session_id}, turn={body.turnId}")
    result = await service.generate_greeting(
        db=db, session_uuid=str(session_id), client_turn_id=body.turnId,
    )
    return result
```

- [ ] **Step 2: Update `generate_greeting` service method**

In `dialogue_service.py`, find `generate_greeting`. Add `client_turn_id` parameter and pass it through to the `DialogueMessage` insert. Also add a lookup-and-replay step at the top:

```python
async def generate_greeting(
    self,
    db: AsyncSession,
    session_uuid: str,
    client_turn_id: str | None = None,
) -> dict:
    # ... existing session lookup ...

    if client_turn_id:
        state, rows = await self._lookup_turn_state(db, session.id, client_turn_id)
        if state == "full" or state == "partial":
            # Greeting only writes one row, so 'partial' here means the row IS the greeting.
            ai_row = next((r for r in rows if r.role == "ai"), None)
            if ai_row:
                return {"aiMessage": ai_row.content}

    # ... existing LLM call + INSERT ...

    ai_msg = DialogueMessage(
        # ... existing fields ...
        client_turn_id=client_turn_id,
    )
    db.add(ai_msg)
    await db.commit()
    return {"aiMessage": ai_text}
```

(Adjust the exact field names to match the existing implementation.)

- [ ] **Step 3: Update `tests/api/test_dialogue_greeting.py`**

Existing tests will fail because `turnId` is now required. Update each greeting POST to include it in the JSON body:

```python
resp = await client.post(
    f"/session/{session_uuid}/greeting",
    json={"turnId": "test-turn-greeting-1"},
)
```

- [ ] **Step 4: Run greeting tests**

```bash
uv run pytest tests/api/test_dialogue_greeting.py -v
```

Expected: green.

- [ ] **Step 5: Add an idempotency test for greeting**

Append to `tests/api/test_dialogue_greeting.py` a test that calls greeting twice with the same turnId and asserts the LLM was called only once:

```python
@pytest.mark.asyncio
async def test_greeting_idempotent_on_retry(test_env):
    client = test_env  # adapt to whatever the fixture returns
    # First call
    r1 = await client.post(
        f"/session/{SESSION}/greeting",
        json={"turnId": "T-greet-1"},
    )
    # Second call same turnId
    r2 = await client.post(
        f"/session/{SESSION}/greeting",
        json={"turnId": "T-greet-1"},
    )
    assert r1.status_code == r2.status_code == 200
    assert r1.json() == r2.json()
    # The mock LLM should have been called once
    # (assert depending on how the mock is exposed; if hard, skip this assertion)
```

(Match the existing test's fixture / app-client style. If the mock LLM isn't reachable from the test, skip the call-count assertion and rely on equality of responses.)

- [ ] **Step 6: Commit**

```bash
git add app/api/dialogue.py app/service/speaking/dialogue_service.py tests/api/test_dialogue_greeting.py
git commit -m "feat(speaking): require turnId on greeting endpoint, idempotent reruns"
```

---

### Task 8: WS `/speak-stream` start frame validates `turnId`

**Files:**
- Modify: `app/api/dialogue.py` — WS handler around line 248–490.

- [ ] **Step 1: Read the WS handler to find the start-frame parsing**

```bash
sed -n '249,310p' /Users/buoy/Development/gitrepo/cococlass-english-speaking-api/app/api/dialogue.py
```

Locate the line that does `start_data = await websocket.receive_json()` and the validation `if not session_uuid: ... 'sessionId required'`.

- [ ] **Step 2: Add `turnId` validation right after `sessionId` validation**

```python
turn_id = start_data.get("turnId")
if not turn_id or not isinstance(turn_id, str):
    await websocket.send_json({"type": "error", "message": "turnId required"})
    await websocket.close()
    return
```

- [ ] **Step 3: Pass `turn_id` into the eventual `service.speak()` call**

The WS handler eventually buffers chunks then calls something like `service.speak(...)`. Pass `client_turn_id=turn_id` there.

- [ ] **Step 4: Smoke check**

There may not be a unit test for the WS path. A manual smoke check via the frontend in Task 16 will validate this.

- [ ] **Step 5: Commit**

```bash
git add app/api/dialogue.py
git commit -m "feat(speaking): require turnId in WS speak-stream start frame"
```

---

### Task 9: Frontend types — `turnId` and `recovery` on `PreviewChatMessage`

**Files:**
- Modify: `/Users/buoy/Development/gitrepo/PPT/src/types/englishSpeaking.ts`

- [ ] **Step 1: Inspect current type**

```bash
grep -n "PreviewChatMessage\|unrecoverable\|recovery" /Users/buoy/Development/gitrepo/PPT/src/types/englishSpeaking.ts
```

- [ ] **Step 2: Add the two new optional fields, remove `unrecoverable`**

Locate the `PreviewChatMessage` interface and apply:

```ts
export interface PreviewChatMessage {
  // ... existing fields ...
  turnId?: string                                       // NEW — set on creation
  recovery?: 'retry' | 'rerecord' | 'restart'          // NEW — set on error
  // unrecoverable?: boolean                            // REMOVED
}
```

- [ ] **Step 3: Type-check**

```bash
cd /Users/buoy/Development/gitrepo/PPT
npx vue-tsc --noEmit 2>&1 | head -40
```

Expected: errors at every existing reference to `unrecoverable` (in `useDialogueEngine.ts` and `DialogueChatView.vue`). These are fixed in subsequent tasks.

- [ ] **Step 4: Commit (with broken type-check noted in commit body)**

```bash
git add src/types/englishSpeaking.ts
git commit -m "types(speaking): add turnId/recovery to PreviewChatMessage

Removes unrecoverable in favor of recovery === 'restart'. Engine and
view will be migrated in subsequent commits — type-check is
intentionally broken between this commit and the next two."
```

---

### Task 10: `llmService.ts` — `speak` and `generateGreeting` accept `turnId`

**Files:**
- Modify: `/Users/buoy/Development/gitrepo/PPT/src/views/Editor/EnglishSpeaking/services/llmService.ts`

- [ ] **Step 1: Inspect current signatures**

```bash
grep -n "speak\|generateGreeting\|FormData\|fetch" /Users/buoy/Development/gitrepo/PPT/src/views/Editor/EnglishSpeaking/services/llmService.ts | head -30
```

- [ ] **Step 2: Add `turnId` to the function signatures + bodies**

For `speak` (FormData multipart):

```ts
async *speak(
  sessionId: string,
  audioBlob: Blob,
  signal: AbortSignal,
  turnId: string,                          // NEW
): AsyncGenerator<DialogueEvent> {
  const form = new FormData()
  form.append('sessionId', sessionId)
  form.append('turnId', turnId)            // NEW
  form.append('audio', audioBlob, 'audio.webm')
  // ... rest unchanged
}
```

For `generateGreeting` (JSON body):

```ts
async generateGreeting(
  sessionId: string,
  signal: AbortSignal,
  turnId: string,                          // NEW
): Promise<{ aiMessage: string }> {
  const res = await fetch(
    `${API_BASE}/session/${sessionId}/greeting`,
    {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ turnId }),    // NEW (was empty body or different)
      signal,
    },
  )
  // ... rest unchanged
}
```

Update the corresponding interface definitions (likely in the same file or in `englishSpeaking.ts`) to add `turnId: string` to the method signatures of `DialogueAPI`.

- [ ] **Step 3: Type-check**

```bash
npx vue-tsc --noEmit 2>&1 | head -40
```

Expected: errors now move to the engine call sites (`useDialogueEngine.ts`) where `speak()` and `generateGreeting()` are called without `turnId`. To be fixed in next tasks.

- [ ] **Step 4: Commit**

```bash
git add src/views/Editor/EnglishSpeaking/services/llmService.ts src/types/englishSpeaking.ts
git commit -m "feat(speaking): plumb turnId through llmService

speak() and generateGreeting() now require turnId in the request.
Engine call sites broken until next commit."
```

---

### Task 11: Engine — `attachSession` accepts `totalRounds`; add `classifyError`

Two small additive changes that don't break anything yet.

**Files:**
- 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` — pass `totalRounds` into `attachSession`.

- [ ] **Step 1: Add `totalRounds` ref + extend `attachSession` signature**

In `useDialogueEngine.ts`, add near other refs:

```ts
const totalRounds = ref<number>(3)
```

Update `attachSession`:

```ts
function attachSession(info: {
  sessionId: string
  expiresAt?: string | null
  totalRounds: number
}) {
  sessionId.value = info.sessionId
  expiresAt.value = info.expiresAt ?? null
  totalRounds.value = info.totalRounds
  if (info.expiresAt) startCountdown(info.expiresAt)
}
```

Export `totalRounds` in the return value. Also expose a computed `isFinalRound`:

```ts
const isFinalRound = computed(() => currentRound.value >= totalRounds.value)
```

Add it to the returned object.

- [ ] **Step 2: Add `classifyError` helper**

Below `friendlyErrorMessage` at the bottom of the file:

```ts
export type Recovery = 'retry' | 'rerecord' | 'restart'

export function classifyError(
  raw: string | undefined,
  status: number | undefined,
  role: 'student' | 'ai',
): Recovery {
  if (status === 404 || status === 409) return 'restart'
  if (raw === 'Session not found' || raw === 'Session is not active') return 'restart'
  if (raw === 'No speech detected' && role === 'student') return 'rerecord'
  return 'retry'
}
```

Export from the engine's return for use by `finalizeError` (added later) — or keep it as a module-private helper used by the engine internals. Both work; a module-level export is cleaner for testing.

- [ ] **Step 3: Update `DialogueChatView.vue` `onMounted` to pass `totalRounds`**

```ts
onMounted(() => {
  if (props.sessionInfo) {
    engine.attachSession({
      sessionId: props.sessionInfo.sessionId,
      expiresAt: props.sessionInfo.expiresAt,
      totalRounds: props.totalRounds,    // NEW
    })
    engine.generateGreeting()
  } else {
    console.warn(...)
  }
})
```

(Adapt the `attachSession` call site to whatever shape currently exists.)

- [ ] **Step 4: Type-check**

```bash
cd /Users/buoy/Development/gitrepo/PPT
npx vue-tsc --noEmit 2>&1 | head -40
```

Errors should now only be: (a) places that still reference `unrecoverable`; (b) `generateGreeting()` call sites missing `turnId`; (c) `speak()` call sites missing `turnId`.

- [ ] **Step 5: Commit**

```bash
git add src/views/Editor/EnglishSpeaking/composables/useDialogueEngine.ts src/views/Editor/EnglishSpeaking/preview/DialogueChatView.vue
git commit -m "feat(speaking): attachSession takes totalRounds; add classifyError helper"
```

---

### Task 12: Engine — restructure `beginStudentStream` (push placeholders at commit, support final-round skip-aiMsg)

This is the biggest engine change. The new contract: `beginStudentStream` opens the WS but pushes nothing; `commit` pushes placeholders and sends `stop`; `abort` closes the WS without touching messages.

**Files:**
- Modify: `/Users/buoy/Development/gitrepo/PPT/src/views/Editor/EnglishSpeaking/composables/useDialogueEngine.ts`

- [ ] **Step 1: Replace the `beginStudentStream` function**

Locate the existing `beginStudentStream` in `useDialogueEngine.ts` (around line 270–386). Replace its body with the new contract:

```ts
function beginStudentStream(opts: {
  sampleRate: number
  bits?: number
  channels?: number
}) {
  if (!sessionId.value || isProcessing.value) return null

  // Generate the turnId for this turn. Caller will use it for retry payloads.
  const turnId = crypto.randomUUID()

  // No messages pushed yet. They are pushed in commit().
  let studentMsg: PreviewChatMessage | null = null
  let aiMsg: PreviewChatMessage | null = null

  const wsUrl = buildWsUrl('/speak-stream')
  const ws = new WebSocket(wsUrl)
  ws.binaryType = 'arraybuffer'

  let aborted = false
  let committed = false
  let chunkQueue: ArrayBuffer[] = []
  let open = false

  const finalizeError = (raw: string) => {
    const text = friendlyErrorMessage(raw)
    if (studentMsg && studentMsg.status === 'loading') {
      studentMsg.status = 'error'
      studentMsg.error = text
      studentMsg.recovery = classifyError(raw, undefined, 'student')
    } else if (aiMsg && aiMsg.status === 'loading') {
      aiMsg.status = 'error'
      aiMsg.error = text
      aiMsg.recovery = classifyError(raw, undefined, 'ai')
    }
  }

  ws.onopen = () => {
    open = true
    ws.send(JSON.stringify({
      type: 'start',
      sessionId: sessionId.value,
      turnId,
      sampleRate: opts.sampleRate,
      bits: opts.bits ?? 16,
      channels: opts.channels ?? 1,
    }))
    for (const c of chunkQueue) ws.send(c)
    chunkQueue = []
  }

  ws.onmessage = (e: MessageEvent) => {
    if (!committed) return  // tokens shouldn't arrive before commit, but defensive
    try {
      const data = JSON.parse(e.data)
      if (data.type === 'transcript' && studentMsg) {
        studentMsg.content = data.text
        studentMsg.status = 'done'
      }
      else if (data.type === 'token' && aiMsg) {
        aiMsg.content += data.content
      }
      else if (data.type === 'done') {
        if (aiMsg) aiMsg.status = 'done'
        else if (studentMsg && studentMsg.status === 'loading') studentMsg.status = 'done'
        isComplete.value = !!data.isComplete
        if (!data.isComplete) currentRound.value++
        ws.close()
      }
      else if (data.type === 'error') {
        finalizeError(data.message)
        ws.close()
      }
    } catch { /* ignore */ }
  }

  ws.onerror = () => {
    if (!aborted && committed) finalizeError('WebSocket error')
  }
  ws.onclose = () => {
    if (!committed || aborted) return
    if (studentMsg?.status === 'loading') finalizeError('Connection closed')
    else if (aiMsg?.status === 'loading') finalizeError('Connection closed')
  }

  const pushChunk = (chunk: ArrayBuffer) => {
    if (aborted) return
    if (open && ws.readyState === WebSocket.OPEN) ws.send(chunk)
    else chunkQueue.push(chunk)
  }

  const commit = (blob: Blob) => {
    if (committed || aborted) return
    committed = true

    studentMsg = reactive<PreviewChatMessage>({
      id: crypto.randomUUID(),
      role: 'student',
      content: '',
      timestamp: new Date(),
      status: 'loading',
      audioBlob: blob,
      turnId,
    })
    messages.value.push(studentMsg)

    if (!isFinalRound.value) {
      aiMsg = reactive<PreviewChatMessage>({
        id: crypto.randomUUID(),
        role: 'ai',
        content: '',
        timestamp: new Date(),
        status: 'loading',
        turnId,
      })
      messages.value.push(aiMsg)
    }

    if (open && ws.readyState === WebSocket.OPEN) {
      ws.send(JSON.stringify({ type: 'stop' }))
    } else {
      // not open yet — close once it opens (rare edge)
      ws.close()
    }
  }

  const abort = () => {
    aborted = true
    try { ws.close() } catch { /* ignore */ }
    // No messages were pushed (commit not called) → nothing to clean up.
  }

  return { turnId, pushChunk, commit, abort }
}
```

- [ ] **Step 2: Remove `attachStudentBlob`**

Delete the `attachStudentBlob` function and remove it from the return object. The blob is now attached inside `commit()`.

- [ ] **Step 3: Remove the old finalizeError module-level / placeholders that the previous version had**

The old version pushed `studentMsg` and `aiMsg` at the top of `beginStudentStream`. Make sure those lines are removed (they should be in the replaced block above).

- [ ] **Step 4: Type-check**

```bash
cd /Users/buoy/Development/gitrepo/PPT
npx vue-tsc --noEmit 2>&1 | head -50
```

Errors should be:
- `DialogueChatView.vue` calling `engine.attachStudentBlob` (it doesn't exist anymore) → fix in next task.
- `DialogueChatView.vue` calling `streamCtl.finish()` instead of `streamCtl.commit()` → fix in next task.

- [ ] **Step 5: Commit**

```bash
git add src/views/Editor/EnglishSpeaking/composables/useDialogueEngine.ts
git commit -m "feat(speaking): split beginStudentStream into begin → commit/abort

Placeholders are pushed at commit() (when user clicks 完成), not at
begin() (which now only opens the WS). abort() is a pure no-op on
the message list. Final-round commits push only studentMsg.
attachStudentBlob is removed (folded into commit)."
```

---

### Task 13: Engine — `finalizeError` for HTTP path uses `classifyError`; add `discardCurrentTurn`; retry/regenerate pass `turnId`

**Files:**
- Modify: `/Users/buoy/Development/gitrepo/PPT/src/views/Editor/EnglishSpeaking/composables/useDialogueEngine.ts`

- [ ] **Step 1: Update HTTP-path error labeling in `sendStudentMessage`**

Inside the catch block of `sendStudentMessage` (around line 131–141), replace direct `.error = ...` assignments with the classifier-aware version:

```ts
} catch (err: any) {
  if (err.name === 'AbortError') return
  const status: number | undefined = err instanceof DialogueApiError ? err.status : undefined
  if (studentMsg.status === 'loading') {
    studentMsg.status = 'error'
    studentMsg.error = friendlyErrorMessage(err.message)
    studentMsg.recovery = classifyError(err.message, status, 'student')
  } else if (aiMsg.status === 'loading') {
    aiMsg.status = 'error'
    aiMsg.error = friendlyErrorMessage(err.message)
    aiMsg.recovery = classifyError(err.message, status, 'ai')
  }
}
```

Apply the same pattern in `regenerateAiMessage`'s catch.

- [ ] **Step 2: Update `generateGreeting` error labeling**

```ts
} catch (err: unknown) {
  if (err instanceof Error && err.name === 'AbortError') return
  aiMsg.status = 'error'
  const raw = err instanceof Error ? err.message : undefined
  aiMsg.error = friendlyErrorMessage(raw)
  const status = err instanceof DialogueApiError ? err.status : undefined
  aiMsg.recovery = classifyError(raw, status, 'ai')
}
```

(Remove the `unrecoverable = status === 404 || status === 409` line — `recovery === 'restart'` covers it now.)

- [ ] **Step 3: Add `discardCurrentTurn` for the 重录 button**

Add to the engine:

```ts
function discardCurrentTurn(messageId: string) {
  const msg = messages.value.find(m => m.id === messageId)
  if (!msg) return
  const turnId = msg.turnId
  if (!turnId) {
    // Defensive: messages from greeting / pre-turnId code path have no turnId.
    messages.value = messages.value.filter(m => m.id !== messageId)
    return
  }
  // Splice out both rows of the turn (student + ai) sharing this turnId.
  messages.value = messages.value.filter(m => m.turnId !== turnId)
}
```

Export it.

- [ ] **Step 4: Update `retryMessage` to use `turnId` (HTTP path)**

The existing `retryMessage` in `useDialogueEngine.ts` calls `sendStudentMessage(blob)` which goes through `api.speak()`. We need that path to include the failing message's `turnId`:

```ts
async function retryMessage(messageId: string) {
  const msg = messages.value.find(m => m.id === messageId)
  if (!msg || msg.status !== 'error') return
  if (msg.role === 'student' && msg.audioBlob && msg.turnId) {
    const idx = messages.value.indexOf(msg)
    messages.value.splice(idx)
    await sendStudentMessage(msg.audioBlob, msg.turnId)
  }
}
```

And `sendStudentMessage` accepts `turnId`:

```ts
async function sendStudentMessage(audioBlob: Blob, turnId: string) {
  // ... existing body ...
  // When pushing the new studentMsg / aiMsg, set their turnId field.
  // When calling api.speak(), pass turnId.
  const generator = api.speak(sessionId.value, audioBlob, currentAbortController.signal, turnId)
  // ...
}
```

Update both `studentMsg` and `aiMsg` construction in `sendStudentMessage` to include `turnId`.

- [ ] **Step 5: Update `regenerateAiMessage` to use the previous student message's `turnId`**

```ts
async function regenerateAiMessage(messageId: string) {
  const msg = messages.value.find(m => m.id === messageId)
  if (!msg || msg.role !== 'ai' || msg.status !== 'error') return

  const idx = messages.value.indexOf(msg)
  const prevStudent = messages.value.slice(0, idx).reverse().find(m => m.role === 'student')
  if (!prevStudent?.audioBlob || !sessionId.value || !prevStudent.turnId) return

  messages.value.splice(idx, 1)

  const aiMsg = reactive<PreviewChatMessage>({
    id: crypto.randomUUID(),
    role: 'ai',
    content: '',
    timestamp: new Date(),
    status: 'loading',
    turnId: prevStudent.turnId,
  })
  messages.value.push(aiMsg)

  currentAbortController = new AbortController()
  try {
    const generator = api.speak(
      sessionId.value, prevStudent.audioBlob,
      currentAbortController.signal, prevStudent.turnId,
    )
    // ... existing event handling ...
  } catch (err: any) {
    if (err.name === 'AbortError') return
    aiMsg.status = 'error'
    aiMsg.error = friendlyErrorMessage(err.message)
    const status = err instanceof DialogueApiError ? err.status : undefined
    aiMsg.recovery = classifyError(err.message, status, 'ai')
  } finally {
    currentAbortController = null
  }
}
```

- [ ] **Step 6: Type-check**

```bash
npx vue-tsc --noEmit 2>&1 | head -40
```

Should be down to view-side errors (`unrecoverable` in the template, `attachStudentBlob` call, etc.).

- [ ] **Step 7: Commit**

```bash
git add src/views/Editor/EnglishSpeaking/composables/useDialogueEngine.ts
git commit -m "feat(speaking): plumb turnId + recovery through retry/regenerate paths

retryMessage/regenerateAiMessage read turnId from the failed message
and forward it to api.speak (HTTP path). Add discardCurrentTurn for
重录. All error finalization paths now set message.recovery via
classifyError."
```

---

### Task 14: Engine — `generateGreeting` allocates a `turnId`

**Files:**
- Modify: `/Users/buoy/Development/gitrepo/PPT/src/views/Editor/EnglishSpeaking/composables/useDialogueEngine.ts`

- [ ] **Step 1: Extract greeting body into a turnId-aware helper, then provide two public callers**

Replace the existing `generateGreeting` and `retryGreeting` functions with this structure:

```ts
async function _runGreeting(turnId: string) {
  if (!sessionId.value || greetingInflight.value) return
  greetingInflight.value = true
  greetingAbortController = new AbortController()

  const aiMsg = reactive<PreviewChatMessage>({
    id: crypto.randomUUID(),
    role: 'ai',
    content: '',
    timestamp: new Date(),
    status: 'loading',
    turnId,
  })
  messages.value.push(aiMsg)

  try {
    const { aiMessage } = await api.generateGreeting(
      sessionId.value, greetingAbortController.signal, turnId,
    )
    aiMsg.content = aiMessage
    aiMsg.status = 'done'
  } catch (err: unknown) {
    if (err instanceof Error && err.name === 'AbortError') return
    aiMsg.status = 'error'
    const raw = err instanceof Error ? err.message : undefined
    aiMsg.error = friendlyErrorMessage(raw)
    const status = err instanceof DialogueApiError ? err.status : undefined
    aiMsg.recovery = classifyError(raw, status, 'ai')
  } finally {
    greetingInflight.value = false
    greetingAbortController = null
  }
}

async function generateGreeting() {
  await _runGreeting(crypto.randomUUID())
}

async function retryGreeting() {
  if (greetingInflight.value) return
  const firstAi = messages.value.find(m => m.role === 'ai')
  if (firstAi?.status !== 'error' || firstAi.recovery === 'restart') return
  const reuseTurnId = firstAi.turnId ?? crypto.randomUUID()
  messages.value = messages.value.filter(m => m.id !== firstAi.id)
  await _runGreeting(reuseTurnId)
}
```

The same `turnId` is reused on retry so the backend's idempotency lookup hits and the user doesn't pay for two LLM calls if the first greeting succeeded server-side but the response was lost in transit. Fallback to a fresh UUID handles the legacy case where a pre-migration greeting message has no `turnId` field.

- [ ] **Step 3: Type-check**

```bash
npx vue-tsc --noEmit 2>&1 | head -40
```

- [ ] **Step 4: Commit**

```bash
git add src/views/Editor/EnglishSpeaking/composables/useDialogueEngine.ts
git commit -m "feat(speaking): greeting carries turnId, retryGreeting reuses it"
```

---

### Task 15: Recorder — accept `AbortSignal`

**Files:**
- Modify: `/Users/buoy/Development/gitrepo/PPT/src/views/Editor/EnglishSpeaking/composables/useAudioRecorder.ts`

- [ ] **Step 1: Update `startRecording` signature**

Around line 77, change:

```ts
async function startRecording(signal?: AbortSignal): Promise<void> {
  pcmChunks = []
  silenceDetected.value = false

  if (signal?.aborted) {
    throw new DOMException('Aborted', 'AbortError')
  }

  try {
    mediaStream = await navigator.mediaDevices.getUserMedia({ audio: true })
    permissionState.value = 'granted'
  } catch (err: any) {
    if (err.name === 'NotAllowedError') {
      permissionState.value = 'denied'
    }
    throw err
  }

  if (signal?.aborted) {
    // User cancelled while permission prompt was open. Release the track.
    mediaStream.getTracks().forEach(t => t.stop())
    mediaStream = null
    throw new DOMException('Aborted', 'AbortError')
  }

  // ... rest of existing setup unchanged ...
}
```

(Two abort checks: pre-getUserMedia and post-getUserMedia. The first is fast; the second is the realistic cancel point.)

- [ ] **Step 2: Type-check**

```bash
npx vue-tsc --noEmit 2>&1 | head -20
```

- [ ] **Step 3: Commit**

```bash
git add src/views/Editor/EnglishSpeaking/composables/useAudioRecorder.ts
git commit -m "feat(speaking): startRecording accepts AbortSignal for cancel during mic acquisition"
```

---

### Task 16: View — add `isStarting` / `reportFetchInflight` refs and update `state` computed

**Files:**
- Modify: `/Users/buoy/Development/gitrepo/PPT/src/views/Editor/EnglishSpeaking/preview/DialogueChatView.vue`

- [ ] **Step 1: Add the refs and abort controller**

In `<script setup>` near other UI state refs:

```ts
const isStarting = ref(false)
const reportFetchInflight = ref(false)
let startAbortController: AbortController | null = null
```

- [ ] **Step 2: Update the `state` type and computed**

```ts
const state = computed<
  'idle' | 'starting' | 'recording' | 'stt' | 'ai_thinking' | 'finalizing' | 'error' | 'done'
>(() => {
  if (isStarting.value) return 'starting'
  if (recorder.isRecording.value) return 'recording'
  if (engine.isComplete.value) return reportFetchInflight.value ? 'finalizing' : 'done'

  const msgs = engine.messages.value
  const last = msgs[msgs.length - 1]
  if (last?.status === 'error') return 'error'
  if (last?.role === 'student' && last.status === 'loading' && !last.content) return 'stt'
  if (engine.isProcessing.value) return 'ai_thinking'
  return 'idle'
})
```

- [ ] **Step 3: Update `fetchReportSafe` to manage `reportFetchInflight`**

```ts
async function fetchReportSafe(): Promise<DialogueReport | null> {
  reportFetchInflight.value = true
  try {
    await engine.completeSession()
    return await engine.getReport()
  } catch (err) {
    console.warn('[speaking] getReport failed:', err)
    return null
  } finally {
    reportFetchInflight.value = false
  }
}
```

- [ ] **Step 4: Type-check + dev server smoke**

```bash
cd /Users/buoy/Development/gitrepo/PPT
npx vue-tsc --noEmit 2>&1 | head -20
```

The view template still references `attachStudentBlob`-related code paths and `streamCtl.finish()` from the old API. Those compile errors are addressed in Task 17.

- [ ] **Step 5: Commit**

```bash
git add src/views/Editor/EnglishSpeaking/preview/DialogueChatView.vue
git commit -m "feat(speaking): wire isStarting/reportFetchInflight refs into state computed"
```

---

### Task 17: View — rewrite `handleStartRecording` / `handleCancelRecording` / `handleFinishRecording`

**Files:**
- Modify: `/Users/buoy/Development/gitrepo/PPT/src/views/Editor/EnglishSpeaking/preview/DialogueChatView.vue`

- [ ] **Step 1: Rewrite `handleStartRecording`**

```ts
let streamCtl: ReturnType<typeof engine.beginStudentStream> | null = null

async function handleStartRecording() {
  if (!engine.canRecord.value || recorder.isRecording.value || isStarting.value) return
  player.stop()

  isStarting.value = true
  startAbortController = new AbortController()

  try {
    await recorder.startRecording(startAbortController.signal)
    // Mic acquired — open the WS now (no placeholders pushed yet).
    streamCtl = engine.beginStudentStream({
      sampleRate: recorder.sampleRate.value,
      bits: 16,
      channels: 1,
    })
    if (streamCtl) {
      recorder.onChunk.value = streamCtl.pushChunk
    }
  } catch (err: any) {
    if (err.name === 'AbortError') {
      // User cancelled during getUserMedia. State naturally returns to idle.
    } else {
      console.error('Failed to start recording:', err)
    }
  } finally {
    isStarting.value = false
    startAbortController = null
  }
}
```

- [ ] **Step 2: Add `handleCancelStarting` (cancel during `starting`)**

```ts
function handleCancelStarting() {
  startAbortController?.abort()
  // recorder.startRecording will reject with AbortError; no further cleanup needed.
}
```

- [ ] **Step 3: Rewrite `handleCancelRecording` (cancel during `recording`)**

```ts
function handleCancelRecording() {
  recorder.onChunk.value = null
  if (streamCtl) {
    streamCtl.abort()  // close WS; nothing on the message list to clean
    streamCtl = null
  }
  if (recorder.isRecording.value) {
    recorder.stopRecording().catch(() => {})
  }
  recorder.cleanup()
}
```

(Note the rename: `streamCtl.abortStream()` → `streamCtl.abort()` to match the new engine API.)

- [ ] **Step 4: Rewrite `handleFinishRecording`**

```ts
async function handleFinishRecording() {
  if (!recorder.isRecording.value) return
  const ctl = streamCtl
  streamCtl = null
  try {
    const blob = await recorder.stopRecording()
    recorder.onChunk.value = null
    if (ctl) {
      ctl.commit(blob)  // pushes placeholders, attaches blob, sends 'stop'
    } else {
      await engine.sendStudentMessage(blob, crypto.randomUUID())
    }
  } catch (err) {
    console.error('Recording/send failed:', err)
  }
}
```

The fallback `sendStudentMessage` path now requires a `turnId`; we generate one fresh since this branch is "no streaming session was open" (rare error case).

- [ ] **Step 5: Type-check**

```bash
npx vue-tsc --noEmit 2>&1 | head -30
```

- [ ] **Step 6: Commit**

```bash
git add src/views/Editor/EnglishSpeaking/preview/DialogueChatView.vue
git commit -m "feat(speaking): rewrite start/cancel/finish handlers for new engine API

Cancel during starting aborts getUserMedia. Cancel during recording
closes the WS but leaves the (empty) message list alone. Finish calls
streamCtl.commit(blob) which is what now pushes placeholders."
```

---

### Task 18: View — render `state-starting` and `state-finalizing` layers

**Files:**
- Modify: `/Users/buoy/Development/gitrepo/PPT/src/views/Editor/EnglishSpeaking/preview/DialogueChatView.vue`

- [ ] **Step 1: Add the `state-starting` layer in the `state-stack`**

Insert after the `state-idle` block in the template (~line 304–331):

```vue
<!-- starting -->
<div class="state-layer state-starting" :style="stateStyle('starting')">
  <div class="record-capsule">
    <button class="cancel-btn" @click="handleCancelStarting">
      <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor"
        stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
        <line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
      </svg>
      取消
    </button>
    <div class="record-meter">
      <svg class="spinner" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
        stroke-width="2" stroke-linecap="round">
        <path d="M21 12a9 9 0 1 1-6.219-8.56" />
      </svg>
      <span class="record-time">准备录音中...</span>
    </div>
    <button class="finish-btn" disabled>
      <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor"
        stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
        <path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" />
        <polyline points="22 4 12 14.01 9 11.01" />
      </svg>
      完成
    </button>
  </div>
</div>
```

- [ ] **Step 2: Add the `state-finalizing` layer**

Insert after the `state-error` block:

```vue
<!-- finalizing -->
<div class="state-layer state-center" :style="stateStyle('finalizing')">
  <svg class="spinner" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor"
    stroke-width="2" stroke-linecap="round">
    <path d="M21 12a9 9 0 1 1-6.219-8.56" />
  </svg>
  <span class="center-text">正在生成你的本次对话报告...</span>
</div>
```

- [ ] **Step 3: Verify in the dev server**

```bash
cd /Users/buoy/Development/gitrepo/PPT
npm run dev
```

Open the preview, start a session. Click 开始录音 — you should see the capsule with spinner + "准备录音中..." for the brief mic-acquisition window (longer on first-permission grant). Cancel during that window; UI returns to idle.

- [ ] **Step 4: Commit**

```bash
git add src/views/Editor/EnglishSpeaking/preview/DialogueChatView.vue
git commit -m "feat(speaking): render starting/finalizing layers in state-stack"
```

---

### Task 19: View — error card with recovery-aware buttons + handlers

**Files:**
- Modify: `/Users/buoy/Development/gitrepo/PPT/src/views/Editor/EnglishSpeaking/preview/DialogueChatView.vue`

- [ ] **Step 1: Replace the error-card buttons (student & AI sides)**

Locate the two `error-card` blocks (one in the AI message branch ~line 117–124, one in the student message branch ~line 202–205) and the `state-error` retry pill ~line 388–394.

Add these helpers in `<script setup>`:

```ts
function hasRetryButton(m: PreviewChatMessage): boolean {
  return m.recovery === 'retry' || m.recovery === 'restart'
}
function hasRerecordButton(m: PreviewChatMessage): boolean {
  return m.role === 'student' && (m.recovery === 'retry' || m.recovery === 'rerecord')
}
function retryButtonLabel(m: PreviewChatMessage): string {
  if (m.recovery === 'restart') return '返回重开'
  return '重试'
}
async function handleRetry(m: PreviewChatMessage) {
  if (m.recovery === 'restart') {
    emit('restart')
    return
  }
  if (m.role === 'student') {
    await engine.retryMessage(m.id)
  } else {
    // greeting case
    const isGreeting = !engine.messages.value
      .slice(0, engine.messages.value.indexOf(m))
      .some(x => x.role === 'student')
    if (isGreeting) {
      await engine.retryGreeting()
    } else {
      await engine.regenerateAiMessage(m.id)
    }
  }
}
function handleRerecord(m: PreviewChatMessage) {
  engine.discardCurrentTurn(m.id)
}
```

Replace the AI-side error card (around line 117):

```vue
<div v-if="message.status === 'error'" class="error-card">
  <span class="error-text">{{ message.error || '生成失败' }}</span>
  <button
    v-if="hasRetryButton(message)"
    class="retry-btn"
    :disabled="engine.greetingInflight.value"
    @click="handleRetry(message)"
  >{{ retryButtonLabel(message) }}</button>
</div>
```

The `disabled` simply prevents double-clicking retry on a greeting while a greeting attempt is in flight; the engine's `retryGreeting()` already guards via `if (greetingInflight.value) return`, so this is purely a UX nicety. For non-greeting AI errors, `greetingInflight` is always false, so the disable has no effect.

Replace the student-side error card (around line 202):

```vue
<div v-if="message.status === 'error'" class="error-card">
  <span class="error-text">{{ message.error || '发送失败' }}</span>
  <button
    v-if="hasRetryButton(message)"
    class="retry-btn"
    @click="handleRetry(message)"
  >{{ retryButtonLabel(message) }}</button>
  <button
    v-if="hasRerecordButton(message)"
    class="rerecord-btn"
    @click="handleRerecord(message)"
  >重录</button>
</div>
```

Add a small CSS rule for `.rerecord-btn` after the existing `.retry-btn` style (around line 1252):

```scss
.rerecord-btn {
  padding: 3px 10px;
  background: transparent;
  border: 1px solid #d1d5db;
  border-radius: 999px;
  font-size: 11px;
  color: #6b7280;
  cursor: pointer;
  margin-left: 4px;
  &:hover { background: #f9fafb; border-color: #9ca3af; color: #374151; }
}
```

- [ ] **Step 2: Replace the `state-error` retry pill (control-zone error layer)**

Around line 388, replace `handleRetry` for the control-zone with the same recovery-aware logic. Reuse the new `handleRetry(message)` by finding the latest errored message:

```vue
<div class="state-layer state-error" :style="stateStyle('error')">
  <div class="error-info">
    <span class="warn-icon">⚠️</span>
    <span class="warn-text">{{ lastErrorText }}</span>
  </div>
  <button
    v-if="lastErroredMessage && hasRetryButton(lastErroredMessage)"
    class="retry-pill"
    @click="handleRetry(lastErroredMessage)"
  >{{ retryButtonLabel(lastErroredMessage) }}</button>
</div>
```

Add a helper computed:

```ts
const lastErroredMessage = computed<PreviewChatMessage | null>(() => {
  const msgs = engine.messages.value
  for (let i = msgs.length - 1; i >= 0; i--) {
    if (msgs[i].status === 'error') return msgs[i]
  }
  return null
})
```

- [ ] **Step 3: Remove the old `retryAiMessage` if it's no longer referenced**

The previous `retryAiMessage` function may have logic for greeting unrecoverable. The new `handleRetry` covers it (`recovery === 'restart'` → emit restart). Delete `retryAiMessage` if its only callers were the old error-card buttons.

- [ ] **Step 4: Type-check**

```bash
npx vue-tsc --noEmit 2>&1 | head -20
```

- [ ] **Step 5: Manual smoke**

```bash
npm run dev
```

- Force a "No speech detected" by recording silence — student error should show only 重录.
- Toggle DevTools → throttle to "Offline" momentarily during a real recording → student error should show 重试 + 重录.

- [ ] **Step 6: Commit**

```bash
git add src/views/Editor/EnglishSpeaking/preview/DialogueChatView.vue
git commit -m "feat(speaking): recovery-aware error card with 重试/重录/返回重开 buttons"
```

---

### Task 20: View — pass `turnId` for greeting on mount + final manual QA

**Files:**
- Modify: `/Users/buoy/Development/gitrepo/PPT/src/views/Editor/EnglishSpeaking/preview/DialogueChatView.vue` — already touched in Task 11 for `attachSession`. Ensure the `engine.generateGreeting()` call doesn't take a `turnId` from the view (the engine generates its own).

This is the final task — pure verification.

- [ ] **Step 1: Confirm greeting still works**

```bash
npm run dev
```

Open a fresh session. Greeting AI message should appear normally.

- [ ] **Step 2: Run the full backend test suite once more**

```bash
cd /Users/buoy/Development/gitrepo/cococlass-english-speaking-api
uv run pytest -v
```

All green.

- [ ] **Step 3: Walk through the manual QA checklist from the spec**

For each item, manually verify and check off:

- [ ] Click 开始录音 with mic permission already granted → `starting` flicker (~50 ms) → `recording`.
- [ ] Click 开始录音 first time (permission prompt) → `starting` visible for ~1–2 s with cancel affordance → cancel works (mic released, no error bubbles).
- [ ] Cancel during recording → no error bubbles in chat, return to idle.
- [ ] Cancel during `starting` → no error bubbles in chat, return to idle, mic released (verify in DevTools → about:mediainternals or Stop indicator).
- [ ] Throttle network in DevTools → Offline, complete a turn, switch back online → 重试 yields the cached content (transcript/AI reply), `currentRound` does NOT double-increment. Verify `current_round` in the DB matches expected.
- [ ] Trigger "No speech detected" by recording silence → student error card shows ONLY 重录 button.
- [ ] Trigger 404 by manually setting `dialogue_session.status = 'completed'` in the DB then sending a turn → error card shows ONLY 返回重开.
- [ ] Reach the final round, click 完成 → no AI bubble appears; `state-finalizing` card shows briefly; transition to report view.

- [ ] **Step 4: Commit (only if there were any final fix-ups)**

```bash
# Only if you had to tweak anything
git add -A
git commit -m "chore(speaking): final QA pass on recording resilience flow"
```

---

## Self-Review

After implementation, before declaring complete, run through this checklist against the spec (`docs/superpowers/specs/2026-04-26-dialogue-recording-resilience-design.md`):

**Spec coverage:**

- ✅ Goal 1 (starting state with cancel) — Tasks 15, 16, 17, 18
- ✅ Goal 2 (取消 is no-op) — Tasks 12, 17 (commit pushes, abort doesn't)
- ✅ Goal 3 (placeholders pushed at commit) — Task 12
- ✅ Goal 4 (recovery-aware errors) — Tasks 11, 13, 19
- ✅ Goal 5 (idempotency) — Tasks 1–8, 13
- ✅ Goal 6 (skip LLM on final round) — Task 5 (backend), Task 12 (frontend)

**Type consistency:**
- `recovery` field uses `'retry' | 'rerecord' | 'restart'` everywhere (engine, view, types).
- `turnId` is `string` (UUID) everywhere.
- `client_turn_id` (snake_case) on the backend SQL/model; `turnId` (camelCase) on API + frontend.

**Placeholder scan:**
- Every step has executable code or commands.
- All file paths are absolute.
- `Task 6` mentions "exact diff depends on the current code shape" — acceptable because the surrounding code is shown in `grep` output during the step.

**Atomic commits:**
- Each task ends with one commit; backend and frontend commits are scoped to their respective repos via working-directory-aware `git add` paths.

---

## Execution Handoff

Plan complete and saved to `docs/superpowers/plans/2026-04-26-dialogue-recording-resilience.md`. Two execution options:

**1. Subagent-Driven (recommended)** — I dispatch a fresh subagent per task, review between tasks, fast iteration. Best for this plan because backend tests + frontend type-check are concrete pass/fail signals each subagent can self-verify.

**2. Inline Execution** — Execute tasks in this session using executing-plans, batch execution with checkpoints.

Which approach?
