diff --git a/packages/answer/src/index.ts b/packages/answer/src/index.ts
index 12d61cdb46ef9f3d96e0aafe512999200fed850d..96a524de0c77d4cab003f401dc26f9b0e1d5d240 100644
--- a/packages/answer/src/index.ts
+++ b/packages/answer/src/index.ts
@@ -31,6 +31,11 @@ import {
} from "@mariozechner/pi-tui";
import { Type } from "@sinclair/typebox";
+/** Escape characters that would break pseudo-XML tag boundaries. */
+function escapeXml(s: string): string {
+ return s.replace(/&/g, "&").replace(//g, ">");
+}
+
// Structured output format for question extraction
interface ExtractedQuestion {
question: string;
@@ -319,12 +324,12 @@ class QnAComponent implements Component {
for (let i = 0; i < this.questions.length; i++) {
const q = this.questions[i];
const a = this.answers[i]?.trim() || "(no answer)";
- parts.push(`${q.question}
`);
- parts.push(`${a}`);
+ parts.push(`${escapeXml(q.question)}
`);
+ parts.push(`${escapeXml(a)}`);
}
parts.push(``);
if (this.notesText) {
- parts.push(`\n${this.notesText}`);
+ parts.push(`\n${escapeXml(this.notesText)}`);
}
this.onDone(parts.join("\n").trim());
@@ -625,7 +630,18 @@ export default function (pi: ExtensionAPI) {
const outcome = await ctx.ui.custom((tui, theme, _kb, done) => {
const extractionModel = resolveExtractionModel(ctx, sessionModel);
const loader = new BorderedLoader(tui, theme, `Extracting questions using ${extractionModel.name}...`);
- loader.onAbort = () => done({ kind: "cancelled" });
+
+ // Guard against double-completion: loader.onAbort fires on user
+ // cancel, but the in-flight promise may also resolve/reject after
+ // the abort. Only the first call to finish() takes effect.
+ let finished = false;
+ const finish = (result: ExtractionOutcome) => {
+ if (finished) return;
+ finished = true;
+ done(result);
+ };
+
+ loader.onAbort = () => finish({ kind: "cancelled" });
const tryExtract = async (model: ReturnType) => {
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
@@ -687,21 +703,21 @@ export default function (pi: ExtensionAPI) {
switch (result.kind) {
case "ok":
- return done({ kind: "ok", result: result.result });
+ return finish({ kind: "ok", result: result.result });
case "cancelled":
- return done({ kind: "cancelled" });
+ return finish({ kind: "cancelled" });
case "model_error":
- return done({
+ return finish({
kind: "error",
message: `${result.model.name} returned an error with no content`,
});
case "parse_error":
- return done({ kind: "error", message: result.message });
+ return finish({ kind: "error", message: result.message });
}
};
doExtract().catch((err) =>
- done({
+ finish({
kind: "error",
message: `${err?.message ?? err}`,
}),