Skip to content
Open
4 changes: 3 additions & 1 deletion source/vscode/ai/qdk-learning.agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,8 @@ Call `get-state` first. If the user is asking to navigate, run, check, reset, et

- **hint** → use the **Hint Strategy** below instead of just calling the tool
- **solution** → warn about spoilers before calling
- **reset** → confirm the user wants to lose their code before calling
- **reset** ("reset this cell", "reset this exercise", "start this over") → `reset`; restores only the current activity — one `.qs` file, or one notebook cell. Confirm the user wants to lose their code before calling.
- **reset unit** ("start this notebook over", "clear my progress on this unit") → `reset-unit`; **python-notebook courses only** — it re-copies the whole notebook from the original and clears the unit's completion, so confirm explicitly and name the unit. Use `list-units` to find the `unitId` when resetting a unit the user isn't currently on. Q# courses have no unit reset; reset their exercises one at a time with `reset`.
- **switch course / list courses / course info** → use the **Courses** tools (`switch-course`, `list-courses`, `course-info`); call `show` after a switch
- **"help with my code" / "debug"** → call `read-code`, then give personalized feedback
- **Q# or QDK question** → if the answer isn't obvious from the current lesson context, **always** read the `/qdk-programming` skill before responding.
Expand Down Expand Up @@ -134,3 +135,4 @@ Render the result, offer a brief reaction. Don't auto-call `next` — the user m
- Don't reveal the solution without a spoiler warning
- Don't invent state — call `get-state` if unsure
- Don't dump raw state JSON to the user
- **Don't hand-edit the learner's workbook or `.qs` files to restore them.** To undo the learner's work, call `reset` (or `reset-unit` for a whole notebook) — they copy the original content verbatim from the course source. Editing the file yourself risks writing code that was never part of the course.
36 changes: 35 additions & 1 deletion source/vscode/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,10 @@
"command": "qsharp-vscode.learningResetUnit",
"when": "false"
},
{
"command": "qsharp-vscode.learningOpenNotebook",
"when": "false"
},
{
"command": "qsharp-vscode.learningShowActivity",
"when": "false"
Expand Down Expand Up @@ -761,6 +765,12 @@
"category": "QDK Learning",
"icon": "$(discard)"
},
{
"command": "qsharp-vscode.learningOpenNotebook",
"title": "Open Course Notebook",
"category": "QDK Learning",
"icon": "$(notebook)"
},
{
"command": "qsharp-vscode.learningShowActivity",
"title": "Show Current Activity",
Expand Down Expand Up @@ -1667,7 +1677,7 @@
],
"toolReferenceName": "qdkLearningReset",
"displayName": "QDK Learning: Reset",
"modelDescription": "Reset the current exercise to its placeholder code and clear its completion. Destructive — requires confirmation. Only valid on exercises.",
"modelDescription": "Reset only the current activity to its original starter code and clear its completion. For python-notebook courses this restores just the current cell (an exercise cell or a plain code cell), leaving the learner's other cells intact; to reset the whole notebook use qdk-learning-reset-unit. For Q# courses this restores the current exercise's .qs file and is only valid on exercises. Destructive — requires confirmation.",
"canBeReferencedInPrompt": true,
"icon": "./resources/file-icon-light.svg",
"inputSchema": {
Expand All @@ -1677,6 +1687,30 @@
"additionalProperties": false
}
},
{
"name": "qdk-learning-reset-unit",
"tags": [
"qdk",
"qdk-learning",
"quantum-katas"
],
"toolReferenceName": "qdkLearningResetUnit",
"displayName": "QDK Learning: Reset Unit",
"modelDescription": "Reset an entire unit's notebook: re-copy it from the course original, discarding all of the learner's edits across every cell and clearing completion for the unit. Python-notebook courses only — Q# courses have no unit reset, so reset their exercises one at a time with qdk-learning-reset. Destructive — all of the user's work in the unit is lost — requires confirmation. Defaults to the current unit; pass unitId from list-units or get-state to reset a different unit in the active course.",
"canBeReferencedInPrompt": true,
"icon": "./resources/file-icon-light.svg",
"inputSchema": {
"type": "object",
"properties": {
"unitId": {
"type": "string",
"description": "ID of the unit to reset (e.g. 'superposition'). Defaults to the current unit."
}
},
"required": [],
"additionalProperties": false
}
},
{
"name": "qdk-create-notebook-venv",
"tags": [
Expand Down
76 changes: 73 additions & 3 deletions source/vscode/src/gh-copilot/learningTools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -326,11 +326,81 @@ export class LearningTools {
*/
async resetExercise(): Promise<StateSnapshot> {
await this.ensureInitialized();
this.throwIfNotQSharpCourse();
return this.invoke(async () => {
await this.service.resetExercise("chat");
// Resolve the target from the editor — the selected cell for notebook
// courses — rather than the stored position, matching hint/solution.
// A destructive reset must never silently act on a different cell, so
// if a workbook is focused but its selected cell can't be identified,
// fail loudly instead of falling back to the stored position.
if (this.notebookSelectionUnidentified()) {
throw new CopilotToolError(
"I couldn't tell which cell is selected — it has no stable id yet. " +
"Click into the exercise cell you want to reset and try again, or reset the whole unit.",
);
}
const state = this.serializeState(true);
await this.service.resetExerciseAt(state.position.location, "chat");
await this.showActivity();
return { state: this.serializeState(false) }; // Q# only
return { state: this.serializeState(true) };
});
}

/**
* True when the learner is on a course workbook but the selected cell has no
* stable id, so {@link serializeState} would fall back to the stored
* position — unsafe for reset. Judged from the active editor's URI so a
* different unit's workbook is still covered.
*/
private notebookSelectionUnidentified(): boolean {
const editor = vscode.window.activeNotebookEditor;
if (!editor || !this.service.isCourseWorkbook(editor.notebook.uri)) {
return false;
}
const selection = editor.selections[0];
if (!selection) {
return true;
}
const cellId = editor.notebook.cellAt(selection.start).metadata?.id;
return typeof cellId !== "string";
}

/**
* Reset an entire unit, clearing completion for all of its activities.
* Defaults to the current unit.
*/
async resetUnit(input?: {
unitId?: string;
}): Promise<{ unitId: string; unitTitle: string } & StateSnapshot> {
await this.ensureInitialized();
return this.invoke(async () => {
// With no explicit unit, resolve the target from the notebook the
// learner is viewing rather than the stored position: sync the position
// to the active workbook first, matching how the other tools resolve
// from the editor. Best-effort — with no workbook focused we fall back
// to the stored current unit.
if (!input?.unitId) {
const activeNotebook = vscode.window.activeNotebookEditor?.notebook.uri;
if (activeNotebook) {
await this.service.syncToWorkbook(activeNotebook);
}
}

// Unit reset is notebook-only; the service rejects Q# courses.
const { unitId, unitTitle } = await this.service.resetUnit(
{ unitId: input?.unitId },
"chat",
);
Comment thread
HABER7789 marked this conversation as resolved.

// The reset closed the workbook and notebook courses don't use the
// lesson panel, so re-open the fresh copy. The open command resolves the
// notebook from the current position, so move there first — the reset
// unit isn't necessarily the one the learner was on.
await this.service.goTo({ unitId }, "chat");
await vscode.commands.executeCommand(
"qsharp-vscode.learningOpenNotebook",
);

return { unitId, unitTitle, state: this.serializeState(false) };
});
}

Expand Down
18 changes: 15 additions & 3 deletions source/vscode/src/gh-copilot/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,11 +184,23 @@ const toolDefinitions: {
{
name: "qdk-learning-reset",
tool: async () => await learningTools!.resetExercise(),
confirm: () => ({
confirm: (): vscode.PreparedToolInvocation => ({
confirmationMessages: {
title: "Reset Exercise",
title: "Reset Activity",
message:
"Reset the current exercise to the original placeholder? Your code will be lost.",
"Reset the current activity to its starter code? Your code will be lost.",
},
}),
},
{
name: "qdk-learning-reset-unit",
tool: async (input) => await learningTools!.resetUnit(input),
confirm: (input: { unitId?: string }): vscode.PreparedToolInvocation => ({
confirmationMessages: {
title: "Reset Unit",
message: input?.unitId
? `Reset unit "${input.unitId}" to its original state? All of your work in this unit will be lost.`
: "Reset the current unit to its original state? All of your work in this unit will be lost.",
},
}),
},
Expand Down
40 changes: 36 additions & 4 deletions source/vscode/src/learning/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,25 +63,57 @@ export function registerLearningCommands(
await service.switchCourse(location.courseId, "tree");
}
await service.goTo(location, "tree");
} else {
// Invoked from the notebook toolbar: point the stored position at the
// notebook the learner is actually looking at before we reset by
// position, so a not-yet-synced editor switch can't reset a different
// unit than the visible one. If we can't confirm which workbook is
// active, don't guess at a destructive reset — abort.
const activeNotebook =
vscode.window.activeNotebookEditor?.notebook.uri;
if (
!activeNotebook ||
!(await service.syncToWorkbook(activeNotebook))
) {
return;
}
}

const confirmed = await vscode.window.showWarningMessage(
"Reset this unit to the original notebook? Your current work will be lost.",
"Reset this unit to its original state? Your current work in this unit will be lost.",
{ modal: true },
"Reset",
);
if (confirmed !== "Reset") {
return;
}

await service.resetExercise();
// The whole unit was reset, so the learner's old position no longer
// means anything — start them at the top of the fresh notebook.
await service.resetUnit(
location ? { unitId: location.unitId } : undefined,
location ? "tree" : "notebook",
);
Comment thread
Copilot marked this conversation as resolved.

// Unit reset is notebook-only and closes the workbook, so re-open the
// fresh copy at the top.
await openCourseNotebook(service, { reveal: "top" });
vscode.window.showInformationMessage("Unit has been reset.");
},
),

// Used by the chat tool to re-open a notebook it closed during a reset.
vscode.commands.registerCommand(
"qsharp-vscode.learningOpenNotebook",
async () => {
if (
!service.initialized ||
!isNotebookCourse(service.getActiveCourseInfo())
) {
return;
}
await openCourseNotebook(service, { reveal: "top" });
},
),

// Progress tree commands

vscode.commands.registerCommand(
Expand Down
95 changes: 95 additions & 0 deletions source/vscode/src/learning/notebookExercises.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,10 @@ interface RawCell {
/** The subset of an nbformat notebook this module reads. */
interface RawNotebook {
cells?: unknown;
metadata?: {
language_info?: { name?: unknown };
kernelspec?: { language?: unknown };
};
}

/** A {@link RawNotebook} whose `cells` array has been validated to exist. */
Expand Down Expand Up @@ -214,6 +218,97 @@ export function stripAuthoringCells(
return `${JSON.stringify(notebook, undefined, 1)}\n`;
}

/**
* The authored form of a single cell: its source, cell kind, and the
* notebook's kernel language. Reset restores all three, so a learner who
* converted the exercise cell to Markdown gets a runnable code cell back
* rather than the starter text stranded in the wrong cell type.
*/
export interface AuthoredCell {
source: string;
kind: "code" | "markdown" | "other";
language: string | undefined;
}

/**
* Read a single cell's authored source and kind out of a notebook's JSON,
* matched by its stable nbformat cell ID, along with the notebook's kernel
* language.
*/
export function findAuthoredCell(
text: string,
cellId: string,
unitLabel: string,
): AuthoredCell | undefined {
const notebook = parseNotebook(text, unitLabel);
if (!notebook) {
return undefined;
}
const cell = notebook.cells.find((c) => cellIdOf(c) === cellId);
if (!cell) {
return undefined;
}
return {
source: cellSource(cell),
kind: cellKind(cell),
language: notebookLanguage(notebook),
};
}

/**
* The notebook's kernel language (e.g. `"python"`), read from its nbformat
* metadata. Used to rebuild a code cell; `undefined` when unspecified.
*/
function notebookLanguage(notebook: ParsedNotebook): string | undefined {
const name = notebook.metadata?.language_info?.name;
if (typeof name === "string") {
return name;
}
const language = notebook.metadata?.kernelspec?.language;
return typeof language === "string" ? language : undefined;
}

/**
* Return the notebook JSON with one cell restored to its authored source and
* kind, matched by its stable nbformat cell ID. Restoring the kind brings a
* cell the learner converted to Markdown back to a runnable code cell. A
* restored code cell is given empty run state, since it has not been run.
*/
export function replaceCellSource(
text: string,
cellId: string,
newSource: string,
newKind: "code" | "markdown" | "other",
unitLabel: string,
): string | undefined {
const notebook = parseNotebook(text, unitLabel);
if (!notebook) {
return undefined;
}

const cell = notebook.cells.find((c) => cellIdOf(c) === cellId);
if (!cell) {
return undefined;
}

const isCode = newKind === "code";
cell.cell_type = isCode ? "code" : "markdown";
cell.source = newSource;
if (isCode) {
// A restored code cell has never been run, so give it empty run state.
(cell as { outputs?: unknown }).outputs = [];
(cell as { execution_count?: unknown }).execution_count = null;
} else {
// Markdown cells carry no run state.
delete (cell as { outputs?: unknown }).outputs;
delete (cell as { execution_count?: unknown }).execution_count;
}

// Match the ipynb serializer's formatting so the file stays diff-stable
// once VS Code starts saving it: one space of indent, trailing newline.
return `${JSON.stringify(notebook, undefined, 1)}\n`;
}

// ─── Cell readers ───

/**
Expand Down
Loading