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.
Backend repository: /Users/buoy/Development/gitrepo/cococlass-english-speaking-api
app/models/dialogue.py
DialogueSession.config_id.init.sql
config_id to fresh installs and add lookup index.migrations/2026-04-27-add-dialogue-session-config-id.sql
app/service/speaking/dialogue_service.py
config_id.DialogueMessage history for latest response.app/api/dialogue.py
configId in create request.GET /sessions/latest route.tests/api/test_dialogue_greeting.py
Frontend repository: /Users/buoy/Development/gitrepo/PPT
src/types/englishSpeaking.ts
configId, userId, historical message/session types, and checking-history preview state.src/views/Editor/EnglishSpeaking/services/llmService.ts
configId/userId on create.getLatestSession(configId, userId).src/views/Editor/EnglishSpeaking/composables/useDialogueEngine.ts
attachSession seed initial messages and current round.src/views/Editor/EnglishSpeaking/preview/DialogueChatView.vue
src/views/Editor/EnglishSpeaking/preview/TopicDiscussionPreview.vue
mode/userid.mode=student.Files:
/Users/buoy/Development/gitrepo/cococlass-english-speaking-api/app/models/dialogue.py/Users/buoy/Development/gitrepo/cococlass-english-speaking-api/init.sql/Users/buoy/Development/gitrepo/cococlass-english-speaking-api/migrations/2026-04-27-add-dialogue-session-config-id.sqlTest: /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:
@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"
Run:
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.
In app/models/dialogue.py, add config_id directly after user_id:
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))
In init.sql, change dialogue_session to include config_id after user_id, and add the lookup index:
user_id VARCHAR(64) NULL,
config_id VARCHAR(36) NULL,
topic VARCHAR(255) NOT NULL,
Add this index after UNIQUE INDEX uk_uuid (uuid):
INDEX idx_dialogue_session_config_user_created (config_id, user_id, created_at)
The resulting tail of dialogue_session should have comma placement like:
completed_at DATETIME NULL,
UNIQUE INDEX uk_uuid (uuid),
INDEX idx_dialogue_session_config_user_created (config_id, user_id, created_at)
);
Create migrations/2026-04-27-add-dialogue-session-config-id.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);
config_id through serviceIn app/service/speaking/dialogue_service.py, update the create_session_only signature:
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:
user_id=user_id,
config_id=config_id,
topic=topic,
configId in create APIIn app/api/dialogue.py, update CreateSessionRequest:
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:
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,
)
Run:
uv run pytest tests/api/test_dialogue_greeting.py::test_post_session_stores_config_and_user -q
Expected: pass.
Run:
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"
Files:
/Users/buoy/Development/gitrepo/cococlass-english-speaking-api/app/api/dialogue.py/Users/buoy/Development/gitrepo/cococlass-english-speaking-api/app/service/speaking/dialogue_service.pyTest: /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:
@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",
}
]
Run:
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.
In app/service/speaking/dialogue_service.py, add this method inside DialogueService, immediately before get_report:
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
],
}
}
In app/api/dialogue.py, add this route after create_session and before /session/{session_id}/greeting so fixed paths remain unambiguous:
@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,
)
Run:
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.
Run:
uv run pytest tests/api/test_dialogue_greeting.py tests/api/test_dialogue_task_hint.py -q
Expected: pass.
Run:
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"
Files:
/Users/buoy/Development/gitrepo/PPT/src/types/englishSpeaking.ts/Users/buoy/Development/gitrepo/PPT/src/views/Editor/EnglishSpeaking/services/llmService.ts/Users/buoy/Development/gitrepo/PPT/src/views/Editor/EnglishSpeaking/composables/useDialogueEngine.tsModify: /Users/buoy/Development/gitrepo/PPT/src/views/Editor/EnglishSpeaking/preview/DialogueChatView.vue
[ ] Step 1: Extend frontend types
In src/types/englishSpeaking.ts, update SessionConfig:
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:
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:
export interface SessionStartInfo {
sessionId: string
expiresAt: string | null
currentRound?: number
messages?: HistoricalDialogueMessage[]
}
Update preview state:
export type PreviewDialogueState = 'checking-history' | 'ready' | 'chatting' | 'completed'
Update DialogueAPI:
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>
}
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:
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:
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()
}
In src/views/Editor/EnglishSpeaking/composables/useDialogueEngine.ts, update imports:
import type { PreviewChatMessage, DialogueAPI, DialogueReport, HistoricalDialogueMessage } from '@/types/englishSpeaking'
Add helper near the session attach section:
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:
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)
}
In src/views/Editor/EnglishSpeaking/preview/DialogueChatView.vue, update the onMounted block:
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 再挂载本组件)
})
Run:
pnpm vue-tsc --noEmit
Expected: pass. If the repo does not define vue-tsc, run:
pnpm tsc --noEmit
Expected: pass.
Run:
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"
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:
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 || '')
Add this function after startDialogue:
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 = '读取历史会话失败,请刷新重试'
}
}
}
In startDialogue, add configId and userId to the api.createSession payload:
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:
preparedSession.value = {
sessionId: info.sessionId,
expiresAt: info.expiresAt,
currentRound: info.currentRound,
}
In the template, above the ready-stage block, add:
<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:
<div v-if="dialogueState === 'ready'" class="ready-stage">
to:
<div v-else-if="dialogueState === 'ready'" class="ready-stage">
Update loadConfigFromBackend:
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:
watch(() => props.configId, (id) => {
historyChecked.value = false
loadConfigFromBackend(id)
})
Run:
pnpm vue-tsc --noEmit
Expected: pass. If unavailable, run:
pnpm tsc --noEmit
Expected: pass.
Run:
git add src/views/Editor/EnglishSpeaking/preview/TopicDiscussionPreview.vue
git commit -m "feat: restore student speaking sessions"
Files:
/Users/buoy/Development/gitrepo/cococlass-english-speaking-apiVerify frontend repository: /Users/buoy/Development/gitrepo/PPT
[ ] Step 1: Run backend tests
Run in backend repo:
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.
Run in frontend repo:
pnpm vue-tsc --noEmit
Expected: pass. If unavailable, run:
pnpm tsc --noEmit
Expected: pass.
With the backend running, create a session:
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:
curl -s 'http://localhost:8000/api/speaking/dialogue/sessions/latest?configId=smoke-config&userId=smoke-user'
Expected JSON:
{
"session": {
"sessionId": "...",
"status": "active",
"totalRounds": 2,
"currentRound": 1,
"expiresAt": null,
"createdAt": "...",
"completedAt": null,
"messages": []
}
}
Use browser URLs that match existing app behavior:
/?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.
/?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.
If verification required code or fixture changes, commit them:
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.
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.configId + userId create and history lookup are covered in Tasks 3 and 4.Placeholder scan:
Type consistency:
configId and userId.config_id and user_id.userid and maps it to backend userId./api/speaking/dialogue/sessions/latest consistently.