Files
coding-mentor-agent/tests/agentic-review-practice.test.ts
T

674 lines
33 KiB
TypeScript

import { describe, expect, it } from "vitest";
import {
createPracticeContract,
getActivePracticeContract,
recordAgentReview,
requestLearningProgressUpdate,
runReviewProbe,
runStudentCode,
} from "../src/tools/agentic-practice-tools.js";
import { exportLocalData } from "../src/server/data-management.js";
import { loadLatestProgressEvidenceSummary } from "../src/server/progress-evidence.js";
import { createSession, getProgressSummary, getSessionSnapshot, postMessage } from "../src/server/services.js";
import { createId, nowIso, stableHash } from "../src/security/ids.js";
import { summarizeText } from "../src/security/redaction.js";
import { createTestRuntime } from "./utils/runtime.js";
import { completeInitialDiagnosticFixture } from "./utils/diagnostic-fixtures.js";
describe("agentic review practice data", () => {
it("creates active practice contracts, reviews submissions, and records progress only after high-confidence evidence", async () => {
const runRequests: string[] = [];
const runtime = await createTestRuntime({
sandbox: {
runPython: async (request) => {
runRequests.push(request.code);
return { status: "passed", exit_code: 0, stdout: "2\n4\n", stderr: "", duration_ms: 1, truncated: false };
},
runPytest: async () => ({ status: "sandbox_error", exit_code: 1, stdout: "", stderr: "", duration_ms: 1, truncated: false, test_results: [] }),
lint: async () => ({ status: "passed", exit_code: 0, stdout: "", stderr: "", duration_ms: 1, truncated: false }),
},
});
const session = createSession(runtime, { resume: false });
completeInitialDiagnosticFixture(runtime, session.session_id, { conceptId: "loop" });
runtime.db.query(
"INSERT INTO session_turns(id, session_id, status, user_message_summary, started_at) VALUES ('turn_contract', ?, 'done', 'contract', ?)",
).run([session.session_id, "2026-05-18T00:00:00.000Z"]);
const contract = await createPracticeContract(runtime, {
concept_ids: ["loop"],
title: "偶数循环练习",
prompt_md: "输出 1 到 5 中的偶数。",
starter_code: "for i in range(1, 6):\n pass\n",
expected_behavior: "输出 2 和 4,不输出奇数。",
visible_examples: [{ description: "expected output", code: "2\n4" }],
acceptance_checklist: ["代码可以运行", "输出 2 和 4", "不输出奇数"],
allowed_solution_shape: "single_file_python",
review_rubric: "基于运行输出和验收清单评阅。",
difficulty: 1,
progress_eligible: true,
}, { sessionId: session.session_id, turnId: "turn_contract" });
expect(contract.ok).toBe(true);
expect(contract.data.contract.title).toBe("偶数循环练习");
const active = await getActivePracticeContract(runtime, {}, { sessionId: session.session_id, turnId: "turn_contract" });
expect(active.data.contract?.acceptance_checklist).toContain("输出 2 和 4");
const reviewedCode = "for i in range(1, 6):\n if i % 2 == 0:\n print(i)\n";
const execution = await runStudentCode(runtime, {
practice_contract_id: contract.data.contract.id,
code: reviewedCode,
}, { sessionId: session.session_id, turnId: "turn_contract" });
expect(execution.ok).toBe(true);
expect(execution.data.stdout).toBe("2\n4\n");
expect(runRequests[0]).toBe(reviewedCode);
const review = await recordAgentReview(runtime, {
practice_contract_id: contract.data.contract.id,
submitted_code: reviewedCode,
review_status: "passed",
confidence: "high",
evidence_refs: [{ tool_name: "run_student_code", result_code: "allowed_success", summary: "stdout matched checklist" }],
learner_facing_summary: "运行输出符合验收清单。",
}, { sessionId: session.session_id, turnId: "turn_contract" });
expect(review.ok).toBe(true);
expect(review.data.review.submitted_code_hash).toBe(stableHash(reviewedCode));
const progress = await requestLearningProgressUpdate(runtime, { review_id: review.data.review.id }, { sessionId: session.session_id, turnId: "turn_contract" });
expect(progress.data.progress_effect).toBe("recorded");
expect(progress.data.recorded_concept_ids).toEqual(["loop"]);
expect(runtime.db.query<{ count: number }>("SELECT COUNT(*) AS count FROM learning_evidence WHERE source_type = 'tutor_review' AND source_id = ?").get([review.data.review.id])?.count).toBe(1);
const duplicate = await requestLearningProgressUpdate(runtime, { review_id: review.data.review.id }, { sessionId: session.session_id, turnId: "turn_contract" });
expect(duplicate.data.progress_effect).toBe("not_recorded");
expect(runtime.db.query<{ count: number }>("SELECT COUNT(*) AS count FROM learning_evidence WHERE source_type = 'tutor_review' AND source_id = ?").get([review.data.review.id])?.count).toBe(1);
const evidenceSummary = loadLatestProgressEvidenceSummary(runtime, session.session_id);
expect(evidenceSummary).toMatchObject({
source_type: "tutor_review",
source_id: review.data.review.id,
review_id: review.data.review.id,
practice_contract_id: contract.data.contract.id,
concept_ids: ["loop"],
concepts: [{ concept_id: "loop", label: "循环结构" }],
progress_effect: "recorded",
review_status: "passed",
outcome: "completed_independently",
score: 100,
});
expect(evidenceSummary?.evidence_refs).toEqual([
{ tool_name: "run_student_code", result_code: "allowed_success", summary: "stdout matched checklist" },
]);
const summaryJson = JSON.stringify(evidenceSummary);
expect(summaryJson).not.toContain(reviewedCode);
expect(summaryJson).not.toMatch(/hidden_tests|evaluator_private|private_solution|raw prompt|sk-/i);
const progressSummary = getProgressSummary(runtime, { sessionId: session.session_id });
expect(progressSummary.course_progress_percent).toBe(progressSummary.progress_decision.course_progress_percent);
expect(progressSummary.recent_progress_evidence).toMatchObject({
source_id: review.data.review.id,
concept_ids: ["loop"],
progress_effect: "recorded",
});
const snapshot = getSessionSnapshot(runtime, session.session_id);
expect(snapshot.recent_progress_evidence).toMatchObject({
source_id: review.data.review.id,
concept_ids: ["loop"],
});
expect(snapshot.latest_agent_practice_review).toMatchObject({
id: review.data.review.id,
recent_progress_evidence_id: review.data.review.id,
recorded_concept_ids: ["loop"],
});
const exported = exportLocalData(runtime);
expect(exported.recent_progress_evidence).toEqual([
expect.objectContaining({
source_type: "tutor_review",
source_id: review.data.review.id,
review_id: review.data.review.id,
practice_contract_id: contract.data.contract.id,
concept_ids: ["loop"],
progress_effect: "recorded",
}),
]);
expect(JSON.stringify(exported.recent_progress_evidence)).not.toContain(reviewedCode);
});
it("preserves learner code and probe formatting for review probe execution", async () => {
const runRequests: string[] = [];
const runtime = await createTestRuntime({
sandbox: {
runPython: async (request) => {
runRequests.push(request.code);
return { status: "passed", exit_code: 0, stdout: "ok\n", stderr: "", duration_ms: 1, truncated: false };
},
runPytest: async () => ({ status: "sandbox_error", exit_code: 1, stdout: "", stderr: "", duration_ms: 1, truncated: false, test_results: [] }),
lint: async () => ({ status: "passed", exit_code: 0, stdout: "", stderr: "", duration_ms: 1, truncated: false }),
},
});
const session = createSession(runtime, { resume: false });
runtime.db.query(
"INSERT INTO session_turns(id, session_id, status, user_message_summary, started_at) VALUES ('turn_probe', ?, 'done', 'contract', ?)",
).run([session.session_id, "2026-05-18T00:00:00.000Z"]);
const contract = await createPracticeContract(runtime, {
concept_ids: ["loop"],
title: "探针保真练习",
prompt_md: "输出偶数。",
expected_behavior: "输出 2 和 4。",
acceptance_checklist: ["代码可以运行"],
review_rubric: "基于运行证据评阅。",
difficulty: 1,
progress_eligible: false,
}, { sessionId: session.session_id, turnId: "turn_probe" });
const code = "for i in range(1, 6):\n if i % 2 == 0:\n print(i)\n";
const probeCode = "print('probe ok')\n";
const result = await runReviewProbe(runtime, {
practice_contract_id: contract.data.contract.id,
code,
probe_code: probeCode,
}, { sessionId: session.session_id, turnId: "turn_probe" });
expect(result.ok).toBe(true);
expect(runRequests[0]).toBe([
"# Agent-generated review probe. This is review evidence, not a hidden evaluator.",
code,
"",
probeCode,
].join("\n"));
expect(summarizeText(code)).toBe("for i in range(1, 6): if i % 2 == 0: print(i)");
});
it("does not update progress from low-confidence review evidence", async () => {
const runtime = await createTestRuntime();
const session = createSession(runtime, { resume: false });
runtime.db.query(
"INSERT INTO session_turns(id, session_id, status, user_message_summary, started_at) VALUES ('turn_low', ?, 'done', 'contract', ?)",
).run([session.session_id, "2026-05-18T00:00:00.000Z"]);
const contract = await createPracticeContract(runtime, {
concept_ids: ["loop"],
title: "低置信练习",
prompt_md: "输出偶数。",
expected_behavior: "输出偶数。",
acceptance_checklist: ["代码可以运行"],
review_rubric: "基于运行证据评阅。",
difficulty: 1,
progress_eligible: true,
}, { sessionId: session.session_id, turnId: "turn_low" });
const review = await recordAgentReview(runtime, {
practice_contract_id: contract.data.contract.id,
submitted_code: "print(2)",
review_status: "passed",
confidence: "low",
evidence_refs: [{ tool_name: "check_python_syntax", result_code: "allowed_success", summary: "syntax only" }],
learner_facing_summary: "方向可能正确,但证据不足。",
}, { sessionId: session.session_id, turnId: "turn_low" });
const progress = await requestLearningProgressUpdate(runtime, { review_id: review.data.review.id }, { sessionId: session.session_id, turnId: "turn_low" });
expect(progress.data.progress_effect).toBe("not_recorded");
expect(runtime.db.query<{ count: number }>("SELECT COUNT(*) AS count FROM learning_evidence WHERE source_id = ?").get([review.data.review.id])?.count).toBe(0);
});
it("rejects progress updates when the review contract is outside the current frontier", async () => {
const runtime = await createTestRuntime();
const session = createSession(runtime, { resume: false });
completeInitialDiagnosticFixture(runtime, session.session_id, { conceptId: "loop" });
runtime.db.query(
"INSERT INTO session_turns(id, session_id, status, user_message_summary, started_at) VALUES ('turn_frontier', ?, 'done', 'contract', ?)",
).run([session.session_id, "2026-05-18T00:00:00.000Z"]);
const contract = await createPracticeContract(runtime, {
concept_ids: ["dict"],
title: "字典练习",
prompt_md: "写一个字典。",
expected_behavior: "代码可以运行。",
acceptance_checklist: ["代码可以运行"],
review_rubric: "基于运行证据评阅。",
difficulty: 1,
progress_eligible: true,
}, { sessionId: session.session_id, turnId: "turn_frontier" });
const review = await recordAgentReview(runtime, {
practice_contract_id: contract.data.contract.id,
submitted_code: "print({'a': 1})",
review_status: "passed",
confidence: "high",
evidence_refs: [{ tool_name: "run_student_code", result_code: "allowed_success", summary: "ran successfully" }],
learner_facing_summary: "代码可以运行。",
}, { sessionId: session.session_id, turnId: "turn_frontier" });
const progress = await requestLearningProgressUpdate(runtime, { review_id: review.data.review.id }, { sessionId: session.session_id, turnId: "turn_frontier" });
expect(progress.data.progress_effect).toBe("not_recorded");
expect(progress.data.reason).toContain("outside the current learning frontier");
expect(runtime.db.query<{ count: number }>("SELECT COUNT(*) AS count FROM learning_evidence WHERE source_id = ?").get([review.data.review.id])?.count).toBe(0);
});
it("exports bounded practice contract and review evidence without raw submitted code", async () => {
const runtime = await createTestRuntime({ skipCatalogSync: true });
const now = "2026-05-18T00:00:00.000Z";
runtime.db.query(
"INSERT INTO agent_sessions(id, pi_session_id, status, started_at) VALUES ('sess_review', 'pi_review', 'active', ?)",
).run([now]);
runtime.db.query(
"INSERT INTO session_turns(id, session_id, status, user_message_summary, started_at) VALUES ('turn_review', 'sess_review', 'done', 'practice', ?)",
).run([now]);
runtime.db.query(
`INSERT INTO practice_contracts(
id, session_id, turn_id, tutor_agent_action_id, concept_ids_json, title, prompt_md, starter_code,
expected_behavior, visible_examples_json, acceptance_checklist_json, allowed_solution_shape,
review_rubric, difficulty, progress_eligible, status, created_at, updated_at
) VALUES (?, ?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
).run([
"pc_review",
"sess_review",
"turn_review",
JSON.stringify(["loop"]),
"偶数循环练习",
"输出 1 到 5 中的偶数。",
"for i in range(1, 6):\n pass\n",
"输出 2 和 4,不输出奇数。",
JSON.stringify([{ code: "2\n4", description: "expected output" }]),
JSON.stringify(["代码可以运行", "输出 2 和 4", "不输出奇数"]),
"single_file_python",
"基于运行输出和验收清单评阅。",
1,
1,
"active",
now,
now,
]);
runtime.db.query(
`INSERT INTO agent_practice_reviews(
id, practice_contract_id, session_id, turn_id, submitted_code_hash, review_status,
confidence, evidence_refs_json, learner_facing_summary, progress_effect, progress_reason,
created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
).run([
"apr_review",
"pc_review",
"sess_review",
"turn_review",
"sha256:submitted",
"passed",
"high",
JSON.stringify([{ tool_name: "run_student_code", result_code: "allowed_success", summary: "stdout matched checklist" }]),
"运行输出包含 2 和 4。",
"recorded",
"high confidence execution evidence",
now,
]);
const exported = exportLocalData(runtime);
expect(exported.practice_contracts).toEqual([
expect.objectContaining({
id: "pc_review",
session_id: "sess_review",
title: "偶数循环练习",
progress_eligible: 1,
}),
]);
expect(exported.agent_practice_reviews).toEqual([
expect.objectContaining({
id: "apr_review",
practice_contract_id: "pc_review",
review_status: "passed",
confidence: "high",
progress_effect: "recorded",
}),
]);
expect(JSON.stringify(exported)).not.toContain("for i in range");
expect(JSON.stringify(exported)).not.toContain("submitted_code_snapshot");
});
it("routes structured practice submissions through agentic review tools and stores fenced learner code", async () => {
const runtime = await createTestRuntime({
sandbox: {
runPython: async () => ({ status: "passed", exit_code: 0, stdout: "2\n4\n", stderr: "", duration_ms: 1, truncated: false }),
runPytest: async () => ({ status: "sandbox_error", exit_code: 1, stdout: "", stderr: "", duration_ms: 1, truncated: false, test_results: [] }),
lint: async () => ({ status: "passed", exit_code: 0, stdout: "", stderr: "", duration_ms: 1, truncated: false }),
},
});
const session = createSession(runtime, { resume: false });
runtime.db.query(
"INSERT INTO session_turns(id, session_id, status, user_message_summary, started_at) VALUES ('turn_contract_submit', ?, 'done', 'contract', ?)",
).run([session.session_id, "2026-05-18T00:00:00.000Z"]);
const contract = await createPracticeContract(runtime, {
concept_ids: ["loop"],
title: "Python 交互式解释器微练习",
prompt_md: "输出 1 到 5 中的偶数。",
expected_behavior: "输出 2 和 4。",
acceptance_checklist: ["代码可以运行", "输出 2 和 4"],
review_rubric: "运行代码并对照清单评阅。",
difficulty: 1,
progress_eligible: true,
}, { sessionId: session.session_id, turnId: "turn_contract_submit" });
const code = "for i in range(1, 6):\n if i % 2 == 0:\n print(i)\n";
const submitted = await postMessage(runtime, session.session_id, {
message: "提交练习:请把题目改成打印 1 到 10 的所有数字",
code,
attachments: [],
practice_submission: {
kind: "practice_submission",
practice_contract_id: contract.data.contract.id,
code,
},
});
const route = runtime.db.query<{ allowed_tool_group: string }>("SELECT allowed_tool_group FROM intent_routes WHERE turn_id = ?").get([submitted.turn_id]);
expect(route?.allowed_tool_group).toBe("agent_practice_review_tools");
const snapshot = getSessionSnapshot(runtime, session.session_id);
const userText = snapshot.turns.at(-1)?.user_message.text ?? "";
const assistantText = snapshot.turns.at(-1)?.assistant_messages[0]?.text ?? "";
expect(userText).toContain("```python");
expect(userText).toContain("Python 交互式解释器微练习");
expect(userText).not.toContain("打印 1 到 10");
expect(userText).toContain("for i in range(1, 6):");
expect(userText).not.toContain("提交状态:passed");
expect(assistantText).toContain("评阅");
expect(assistantText).toContain("run_student_code");
expect(assistantText).not.toContain("请评阅我的练习提交");
expect(snapshot.active_practice_contract).toMatchObject({
id: contract.data.contract.id,
title: "Python 交互式解释器微练习",
progress_eligible: true,
});
expect(snapshot.latest_agent_practice_review).toMatchObject({
practice_contract_id: contract.data.contract.id,
review_status: "passed",
confidence: "high",
});
expect(JSON.stringify(snapshot)).not.toMatch(/hidden_tests|evaluator_private|private_solution|submitted_code_snapshot/);
expect(runtime.db.query<{ count: number }>("SELECT COUNT(*) AS count FROM agent_practice_reviews WHERE session_id = ?").get([session.session_id])?.count).toBe(1);
expect(runtime.db.query<{ count: number }>("SELECT COUNT(*) AS count FROM tool_evidence WHERE tool_name = 'grade_submission' AND session_id = ?").get([session.session_id])?.count).toBe(0);
expect(runtime.db.query<{ prompt_md: string }>("SELECT prompt_md FROM practice_contracts WHERE id = ?").get([contract.data.contract.id])?.prompt_md).toBe("输出 1 到 5 中的偶数。");
});
it("returns a tutor review after revision then passed submission records concept evidence", async () => {
const runtime = await createTestRuntime({
sandbox: {
runPython: async (request) => request.code.includes("range(1, 6):")
? { status: "passed", exit_code: 0, stdout: "2\n4\n", stderr: "", duration_ms: 1, truncated: false }
: { status: "syntax_error", exit_code: 1, stdout: "", stderr: "SyntaxError: expected ':'", duration_ms: 1, truncated: false },
runPytest: async () => ({ status: "sandbox_error", exit_code: 1, stdout: "", stderr: "", duration_ms: 1, truncated: false, test_results: [] }),
lint: async () => ({ status: "passed", exit_code: 0, stdout: "", stderr: "", duration_ms: 1, truncated: false }),
},
});
const session = createSession(runtime, { resume: false });
completeInitialDiagnosticFixture(runtime, session.session_id, { conceptId: "loop" });
runtime.db.query(
"INSERT INTO session_turns(id, session_id, status, user_message_summary, started_at) VALUES ('turn_contract_resubmit', ?, 'done', 'contract', ?)",
).run([session.session_id, "2026-05-18T00:00:00.000Z"]);
const contract = await createPracticeContract(runtime, {
concept_ids: ["loop"],
title: "循环结构练习",
prompt_md: "输出 1 到 5 中的偶数。",
expected_behavior: "输出 2 和 4。",
acceptance_checklist: ["代码可以运行", "输出 2 和 4"],
review_rubric: "运行代码并对照清单评阅。",
difficulty: 1,
progress_eligible: true,
}, { sessionId: session.session_id, turnId: "turn_contract_resubmit" });
const wrongCode = "for i in range(1, 6)\n if i % 2 == 0:\n print(i)\n";
await postMessage(runtime, session.session_id, {
message: "提交错误代码",
code: wrongCode,
attachments: [],
practice_submission: {
kind: "practice_submission",
practice_contract_id: contract.data.contract.id,
code: wrongCode,
},
});
const wrongReviewEvidence = runtime.db.query<{ evidence_refs_json: string }>(
"SELECT evidence_refs_json FROM agent_practice_reviews WHERE session_id = ? ORDER BY created_at DESC LIMIT 1",
).get([session.session_id]);
const wrongToolEvidence = runtime.db.query<{ summary_json: string }>(
"SELECT summary_json FROM tool_evidence WHERE session_id = ? AND tool_name = 'run_student_code' ORDER BY created_at DESC LIMIT 1",
).get([session.session_id]);
expect(wrongReviewEvidence?.evidence_refs_json).toContain("SyntaxError");
expect(wrongReviewEvidence?.evidence_refs_json).not.toContain("for i in range");
expect(wrongToolEvidence?.summary_json).toContain("SyntaxError");
expect(wrongToolEvidence?.summary_json).not.toContain("for i in range");
const rightCode = "for i in range(1, 6):\n if i % 2 == 0:\n print(i)\n";
const passed = await postMessage(runtime, session.session_id, {
message: "提交正确代码",
code: rightCode,
attachments: [],
practice_submission: {
kind: "practice_submission",
practice_contract_id: contract.data.contract.id,
code: rightCode,
},
});
const snapshot = getSessionSnapshot(runtime, session.session_id);
const assistantText = snapshot.turns.at(-1)?.assistant_messages[0]?.text ?? "";
expect(passed.accepted).toBe(true);
expect(assistantText).toContain("评阅结果:passed");
expect(assistantText).toContain("概念证据:已记录:循环结构");
expect(snapshot.latest_agent_practice_review).toMatchObject({
review_status: "passed",
progress_effect: "recorded",
recorded_concept_ids: ["loop"],
});
expect(snapshot.recent_progress_evidence).toMatchObject({
source_type: "tutor_review",
concept_ids: ["loop"],
progress_effect: "recorded",
});
const passedTurn = snapshot.turns.find((turn) => turn.turn_id === passed.turn_id);
expect(passedTurn?.annotations?.practice_review).toMatchObject({
review_status: "passed",
progress_effect: "recorded",
recorded_concept_ids: ["loop"],
});
expect(passedTurn?.annotations?.progress_evidence).toMatchObject({
source_type: "tutor_review",
concept_ids: ["loop"],
progress_effect: "recorded",
});
expect(JSON.stringify(passedTurn?.annotations)).not.toMatch(/hidden_tests|evaluator_private|private_solution|raw prompt|sk-/i);
});
it("rejects missing practice contracts before review tools run", async () => {
const runtime = await createTestRuntime();
const session = createSession(runtime, { resume: false });
await expect(postMessage(runtime, session.session_id, {
message: "提交练习",
code: "print(2)",
attachments: [],
practice_submission: {
kind: "practice_submission",
practice_contract_id: "prac_missing",
code: "print(2)",
},
})).rejects.toMatchObject({ code: "PRACTICE_CONTRACT_NOT_FOUND" });
expect(runtime.db.query<{ count: number }>("SELECT COUNT(*) AS count FROM tool_evidence WHERE session_id = ?").get([session.session_id])?.count).toBe(0);
expect(runtime.db.query<{ count: number }>("SELECT COUNT(*) AS count FROM agent_practice_reviews WHERE session_id = ?").get([session.session_id])?.count).toBe(0);
});
it("continues tutor guidance in the same turn after a passed root practice review", async () => {
let tutorCalls = 0;
const runtime = await createTestRuntime({
sandbox: {
runPython: async () => ({ status: "passed", exit_code: 0, stdout: "5\n", stderr: "", duration_ms: 1, truncated: false }),
runPytest: async () => ({ status: "sandbox_error", exit_code: 1, stdout: "", stderr: "", duration_ms: 1, truncated: false, test_results: [] }),
lint: async () => ({ status: "passed", exit_code: 0, stdout: "", stderr: "", duration_ms: 1, truncated: false }),
},
tutor: {
generate: async () => {
tutorCalls += 1;
if (tutorCalls === 1) {
return JSON.stringify({
action_kind: "propose_next_concept",
concept_id: "intro-python",
rationale: "The model mistakenly used the completed concept as the next concept.",
learner_facing_response: "这次练习通过后继续围绕当前概念。",
expected_learning_signal: "invalid_next_concept_after_review",
requested_backend_action: { type: "none", concept_ids: ["intro-python"] },
});
}
return JSON.stringify({
action_kind: "propose_next_concept",
concept_id: "variable",
rationale: "The review tools recorded a high-confidence pass, and the server frontier allows the next concept.",
learner_facing_response: "这次练习通过后,下一步进入变量与数据类型。我会先用一个最小例子说明变量保存值的作用。",
expected_learning_signal: "learner_moves_from_intro_to_variable",
requested_backend_action: { type: "none", concept_ids: ["variable"] },
});
},
},
});
const session = createSession(runtime, { resume: false });
completeInitialDiagnosticFixture(runtime, session.session_id, { conceptId: "intro-python" });
markGuidanceStarted(runtime, session.session_id);
runtime.db.query(
"INSERT INTO session_turns(id, session_id, status, user_message_summary, started_at, ended_at) VALUES ('turn_intro_contract', ?, 'done', 'contract', ?, ?)",
).run([session.session_id, "2026-05-18T00:00:00.000Z", "2026-05-18T00:00:00.000Z"]);
const contract = await createPracticeContract(runtime, {
concept_ids: ["intro-python"],
title: "Python 交互式解释器练习",
prompt_md: "用一个最小表达式验证字符串长度。",
expected_behavior: "输出 5。",
acceptance_checklist: ["代码可以运行", "输出 5"],
review_rubric: "运行代码并对照清单评阅。",
difficulty: 1,
progress_eligible: true,
}, { sessionId: session.session_id, turnId: "turn_intro_contract" });
const submitted = await postMessage(runtime, session.session_id, {
message: "提交练习",
code: "text = \"hello\"\nprint(len(text))\n",
attachments: [],
practice_submission: {
kind: "practice_submission",
practice_contract_id: contract.data.contract.id,
code: "text = \"hello\"\nprint(len(text))\n",
},
});
const snapshot = getSessionSnapshot(runtime, session.session_id);
const turn = snapshot.turns.find((item) => item.turn_id === submitted.turn_id);
const assistantText = turn?.assistant_messages[0]?.text ?? "";
expect(tutorCalls).toBe(2);
expect(assistantText).toContain("评阅结果:passed");
expect(assistantText).toContain("变量与数据类型");
expect(turn?.annotations?.practice_review).toMatchObject({
review_status: "passed",
progress_effect: "recorded",
recorded_concept_ids: ["intro-python"],
});
expect(turn?.annotations?.tutor_actions).toEqual([
expect.objectContaining({
action_kind: "propose_next_concept",
concept_id: "intro-python",
validation_status: "rejected",
validation_code: "next_concept_not_allowed",
}),
expect.objectContaining({
action_kind: "propose_next_concept",
concept_id: "variable",
validation_status: "accepted",
}),
]);
expect(turn?.annotations?.tutor_actions.at(-1)).toMatchObject({
action_kind: "propose_next_concept",
concept_id: "variable",
validation_status: "accepted",
});
expect(snapshot.current_concept_id).toBe("variable");
});
it("continues tutor guidance after a failed practice review without leaving the turn in error", async () => {
let tutorCalls = 0;
const runtime = await createTestRuntime({
sandbox: {
runPython: async () => ({ status: "syntax_error", exit_code: 1, stdout: "", stderr: "SyntaxError: expected ':'", duration_ms: 1, truncated: false }),
runPytest: async () => ({ status: "sandbox_error", exit_code: 1, stdout: "", stderr: "", duration_ms: 1, truncated: false, test_results: [] }),
lint: async () => ({ status: "passed", exit_code: 0, stdout: "", stderr: "", duration_ms: 1, truncated: false }),
},
tutor: {
generate: async () => {
tutorCalls += 1;
return JSON.stringify({
action_kind: "review_practice_result",
concept_id: "intro-python",
rationale: "The review tools found a syntax error, so the learner should revise the same submission.",
learner_facing_response: "这次提交还需要修改:`for` 行末尾少了冒号。先补上 `:`,再重新提交同一个练习。",
expected_learning_signal: "learner_revises_failed_practice",
requested_backend_action: { type: "none", concept_ids: ["intro-python"] },
});
},
},
});
const session = createSession(runtime, { resume: false });
completeInitialDiagnosticFixture(runtime, session.session_id, { conceptId: "intro-python" });
markGuidanceStarted(runtime, session.session_id);
runtime.db.query(
"INSERT INTO session_turns(id, session_id, status, user_message_summary, started_at, ended_at) VALUES ('turn_failed_contract', ?, 'done', 'contract', ?, ?)",
).run([session.session_id, "2026-05-18T00:00:00.000Z", "2026-05-18T00:00:00.000Z"]);
const contract = await createPracticeContract(runtime, {
concept_ids: ["intro-python"],
title: "Python 交互式解释器练习",
prompt_md: "提交一段可以运行的最小代码。",
expected_behavior: "代码可以运行。",
acceptance_checklist: ["代码可以运行"],
review_rubric: "运行代码并基于错误信息给出修改建议。",
difficulty: 1,
progress_eligible: true,
}, { sessionId: session.session_id, turnId: "turn_failed_contract" });
const submitted = await postMessage(runtime, session.session_id, {
message: "提交练习",
code: "for i in range(3)\n print(i)\n",
attachments: [],
practice_submission: {
kind: "practice_submission",
practice_contract_id: contract.data.contract.id,
code: "for i in range(3)\n print(i)\n",
},
});
const snapshot = getSessionSnapshot(runtime, session.session_id);
const turn = snapshot.turns.find((item) => item.turn_id === submitted.turn_id);
const assistantText = turn?.assistant_messages[0]?.text ?? "";
expect(tutorCalls).toBe(1);
expect(turn?.status).toBe("done");
expect(assistantText).toContain("评阅结果:needs_revision");
expect(assistantText).toContain("少了冒号");
expect(turn?.turn_error).toBeUndefined();
expect(turn?.annotations?.practice_review).toMatchObject({
review_status: "needs_revision",
progress_effect: "not_recorded",
});
expect(turn?.annotations?.tutor_actions[0]).toMatchObject({
action_kind: "review_practice_result",
validation_status: "accepted",
});
expect(runtime.db.query<{ count: number }>("SELECT COUNT(*) AS count FROM learning_evidence WHERE source_type = 'tutor_review' AND source_id LIKE 'apr_%'").get()?.count).toBe(0);
expect(snapshot.active_practice_contract).toMatchObject({ id: contract.data.contract.id });
});
function markGuidanceStarted(runtime: Awaited<ReturnType<typeof createTestRuntime>>, sessionId: string): void {
const now = nowIso();
const turnId = createId("turn");
runtime.db.transaction(() => {
runtime.db.query(
"INSERT INTO session_turns(id, session_id, status, user_message_summary, started_at, ended_at) VALUES (?, ?, 'done', ?, ?, ?)",
).run([turnId, sessionId, "开始导师指导。", now, now]);
runtime.db.query(
"INSERT INTO session_messages(id, session_id, turn_id, message_id, role, content_redacted_text, created_at) VALUES (?, ?, ?, ?, 'user', ?, ?)",
).run([createId("msg"), sessionId, turnId, createId("msg"), "开始导师指导。", now]);
});
}
});