fix(P06): defend page keys defense+grading on the REAL variant task_id (verifier P0)

The defend page fabricated taskId = `task-${competencyId}`, but variant
task_ids are `task-<seed[:16]>` (D-029) — so in the real browser flow the
defense ran against an empty trace (no digest grounding) and Grade My Work
always returned UNGRADABLE_EMPTY_TRACE. The E2E test masked this by passing
the real task_id directly.

Fix: DefenseSession resolves the learner's stored variant by competency
via GET /v1/variants?learner_id (new listVariants client), defends + grades
under the variant's real task_id, and shows an honest empty state when no
build session exists for the competency yet.

---ci---
phase: 6
milestone: v0.3
status: verify
requirements:
  covered: [REQ-3-008]
  partial: []
lessons:
  - A green E2E test can still mask a broken UI wiring when the test hand-picks
    the join key the UI is supposed to derive; verify browser flows against the
    ids the pages actually construct, not the engine contract alone.
---/ci---
This commit is contained in:
CIAgent
2026-09-12 18:27:43 +00:00
parent ed243594d2
commit a905eb8c67
3 changed files with 58 additions and 2 deletions
@@ -38,7 +38,7 @@ export default async function DefensePage({
then request your process-trace grade. then request your process-trace grade.
</p> </p>
</header> </header>
<DefenseSession taskId={`task-${competencyId}`} /> <DefenseSession competencyId={competencyId} />
</div> </div>
); );
} }
@@ -14,6 +14,7 @@ import {
answerDefense, answerDefense,
finishDefense, finishDefense,
getDefense, getDefense,
listVariants,
requestGrade, requestGrade,
startDefense, startDefense,
} from '../../lib/engine-client'; } from '../../lib/engine-client';
@@ -25,8 +26,16 @@ type MicState = 'idle' | 'recording' | 'denied' | 'unsupported';
* MediaRecorder when permitted (multipart POST), browser-SR fallback per * MediaRecorder when permitted (multipart POST), browser-SR fallback per
* the server's voice descriptor, examiner follow-ups, and the final verdict * the server's voice descriptor, examiner follow-ups, and the final verdict
* rendered with integrity signals. No mock defense data anywhere. * rendered with integrity signals. No mock defense data anywhere.
*
* Task identity: the defense + grading engines key on the learner's REAL
* variant task_id (task-<seed16>, derived server-side from the variant
* seed), NOT a fabricated per-competency id — the defense must question
* THIS build session's trace digest and the grade must read THIS build
* session's trace. `competencyId` resolves the stored variant; the engine
* client fabricates nothing.
*/ */
export function DefenseSession({ taskId }: { taskId: string }) { export function DefenseSession({ competencyId }: { competencyId: string }) {
const [taskId, setTaskId] = useState<string | null>(null);
const [defenseId, setDefenseId] = useState<string | null>(null); const [defenseId, setDefenseId] = useState<string | null>(null);
const [turns, setTurns] = useState<DefenseTurn[]>([]); const [turns, setTurns] = useState<DefenseTurn[]>([]);
const [answerText, setAnswerText] = useState(''); const [answerText, setAnswerText] = useState('');
@@ -42,7 +51,28 @@ export function DefenseSession({ taskId }: { taskId: string }) {
setTurns(session.turns ?? []); setTurns(session.turns ?? []);
}, []); }, []);
// Resolve the learner's stored variant for this competency — the real
// task key the defense and grading engines trace-key on. A learner who
// has not built yet has no variant: the panel says so (an honest empty
// state) instead of defending a phantom task with no trace.
useEffect(() => {
let cancelled = false;
void (async () => {
try {
const variants = await listVariants(MOCK_LEARNER_ID);
const mine = variants.find((v) => v.competency_id === competencyId);
if (!cancelled) setTaskId(mine?.task_id ?? null);
} catch {
if (!cancelled) setTaskId(null);
}
})();
return () => {
cancelled = true;
};
}, [competencyId]);
const start = useCallback(async () => { const start = useCallback(async () => {
if (!taskId) return;
setBusy(true); setBusy(true);
setError(null); setError(null);
try { try {
@@ -118,6 +148,7 @@ export function DefenseSession({ taskId }: { taskId: string }) {
}, [defenseId, refresh]); }, [defenseId, refresh]);
const gradeWork = useCallback(async () => { const gradeWork = useCallback(async () => {
if (!taskId) return;
setBusy(true); setBusy(true);
try { try {
const record = await requestGrade(MOCK_LEARNER_ID, taskId); const record = await requestGrade(MOCK_LEARNER_ID, taskId);
@@ -131,6 +162,19 @@ export function DefenseSession({ taskId }: { taskId: string }) {
useEffect(() => () => recorderRef.current?.stop(), []); useEffect(() => () => recorderRef.current?.stop(), []);
if (!taskId) {
return (
<div className="flex flex-col items-center gap-3 rounded-lg border border-slate-200 bg-white px-6 py-10 text-center dark:border-slate-700 dark:bg-slate-900">
<p className="text-sm text-slate-600 dark:text-slate-300">
No build session found for this competency yet start the task on
the build page first. The defense questions you about YOUR build
session&apos;s trace.
</p>
{error && <p className="text-xs text-amber-600 dark:text-amber-400">{error}</p>}
</div>
);
}
if (!defenseId) { if (!defenseId) {
return ( return (
<div className="flex flex-col items-center gap-3 rounded-lg border border-slate-200 bg-white px-6 py-10 text-center dark:border-slate-700 dark:bg-slate-900"> <div className="flex flex-col items-center gap-3 rounded-lg border border-slate-200 bg-white px-6 py-10 text-center dark:border-slate-700 dark:bg-slate-900">
+12
View File
@@ -181,6 +181,18 @@ export async function generateVariant(
}); });
} }
/** The learner's stored variants, chronological (D-029 — one per template). */
export async function listVariants(
learnerId: string,
signal?: AbortSignal,
): Promise<TaskVariant[]> {
const body = await jsonFetch<{ variants: TaskVariant[] }>(
`/v1/variants?learner_id=${encodeURIComponent(learnerId)}`,
{ signal },
);
return body.variants ?? [];
}
// -- grading (REQ-3-004) ------------------------------------------------------- // -- grading (REQ-3-004) -------------------------------------------------------
export async function requestGrade( export async function requestGrade(