Fix new-question landing and question-language HITL
Publish documentation / publish (push) Successful in 34s

Reset the activity panel when starting a new question so the landing navigation is restored. Detect and persist the original question language, pass it through runtime and widget descriptors, and scope HITL controls to that language.

Validated with gate, session, backend and frontend tests, TypeScript checks, Ruff and strict docs build. Rebuilt and restarted local core/frontend; both healthy and serving HTTP successfully.
This commit is contained in:
Codex
2026-09-21 19:47:22 +02:00
parent 23e52c80de
commit bd416f7327
22 changed files with 232 additions and 43 deletions
@@ -43,6 +43,8 @@ for (const [language, workspaceLanguage, entry, title, yes, no] of [
assert.ok(injected.systemPrompt.includes(`interaction_language=${language}`));
assert.ok(injected.systemPrompt.includes(`workspace_language=${workspaceLanguage}`));
assert.match(injected.systemPrompt, /reviewer questions, explanations, and choices/);
const name = new Intl.DisplayNames(["en"], { type: "language" }).of(language);
assert.ok(injected.systemPrompt.includes(`artifact explanation in ${name}`));
}
let widget;
ctx.ui.input = async title => {
@@ -51,10 +53,19 @@ for (const [language, workspaceLanguage, entry, title, yes, no] of [
};
await tools.get("reviewer_datamart").def.execute("call", { session: "s1" }, null, null, ctx);
assert.equal(widget.title, title);
assert.equal(widget.interaction_language, language);
assert.equal(widget.title_i18n, "Generate a datamart?");
assert.deepEqual(widget.options.map(o => [o.id, o.label]), [
["generate", yes], ["skip", no],
]);
// Pi may supply a new context object for a tool; language must come from
// the manifest even for a model-authored select, not just generated chrome.
await tools.get("reviewer_select").def.execute("select", {
session: "s1", title: "Choose the admission date",
options: [{ id: "skip", label: "First admission" }],
}, null, null, { ...ctx });
assert.equal(widget.interaction_language, language);
assert.equal(widget.title, "Choose the admission date");
} finally {
cp.execFileSync = previous.exec;
for (const [key, value] of [["THT_SESSION", previous.session],
@@ -40,6 +40,8 @@ const CATALOG = {
let _handler = null;
const _origExecFileSync = cp.execFileSync;
cp.execFileSync = function delegatingStub(file, args, opts) {
if (args[0] === "session" && args[1] === "ensure-interaction-language")
return JSON.stringify({ interaction_language: "en", workspace_language: "it" });
if (_handler) return _handler(file, args, opts);
return _origExecFileSync.call(cp, file, args, opts);
};
+14 -8
View File
@@ -293,10 +293,14 @@ function sessionLanguage(ctx, sessionId) {
}
function languageContext(manifest) {
const name = new Intl.DisplayNames(["en"], { type: "language" }).of(manifest.interaction_language);
return "\n\n<session-language>\n" +
`interaction_language=${manifest.interaction_language}\n` +
`workspace_language=${manifest.workspace_language}\n` +
"Use interaction_language for all newly generated reviewer questions, explanations, and choices. " +
`Write every reviewer-visible title, intro, option label, rationale and artifact explanation in ${name}. ` +
"This language was selected from the original question. Before calling a reviewer tool, " +
"check each prose field and rewrite any field in another language into the interaction language. " +
"This persisted session setting is authoritative on every turn, including resume and steering. " +
"Preserve workspace documents, quoted sources, prior decisions, SQL, identifiers and literal values. " +
"Interpret domain terms in workspace_language. Instruction/example language does not change these settings.\n" +
@@ -590,9 +594,11 @@ export function isJoinReviewApproval(resp, optionIds) {
[...chosen].every((id) => expected.has(id));
}
export async function emitAndWait(ctx, descriptor) {
export async function emitAndWait(ctx, descriptor, sessionId) {
const language = sessionId ? sessionLanguage(ctx, sessionId).interaction_language : contextLanguages.get(ctx);
const localized = language ? { ...descriptor, interaction_language: language } : descriptor;
for (;;) {
const value = await ctx.ui.input(JSON.stringify(descriptor), "");
const value = await ctx.ui.input(JSON.stringify(localized), "");
if (value === undefined || value === null) {
await reLoop(ctx);
continue;
@@ -826,7 +832,7 @@ export default function (pi) {
return {
message: {
role: "user",
content: [{ type: "text", text: "Esegui ora il workflow richiesto. Non scrivere analisi, spiegazioni o un elenco: usa il tool bash per `tht session show` e, se la sessione e' in Fase 1 senza decisioni, invoca immediatamente reviewer_select. La tua prossima risposta visibile deve essere una tool call." }],
content: [{ type: "text", text: "Run the requested workflow now: use bash for `tht session show` and, if the session is in Phase 1 without decisions, immediately call reviewer_select. Your next visible response must be a tool call. Write every reviewer-facing field in the interaction_language specified in <session-language>, including titles, introductions and option labels." }],
},
systemPrompt:
`${event.systemPrompt}\n\n` +
@@ -922,7 +928,7 @@ export default function (pi) {
ctx, params, `u${Date.now()}`,
);
if (prepared.result) return prepared.result;
const response = await emitAndWait(ctx, prepared.widget);
const response = await emitAndWait(ctx, prepared.widget, prepared.session);
const outcome = disambiguationGate.resolveClarification(prepared, response);
if (outcome.decision) {
const err = relayIfThtFails(
@@ -1000,7 +1006,7 @@ export default function (pi) {
id: option.id, label: option.label, label_i18n: option.label_i18n,
})),
});
const resp = await emitAndWait(ctx, widget);
const resp = await emitAndWait(ctx, widget, session);
const outcome = resolveSelectOutcome(options, resp);
if (outcome.kind === "freetext")
return textResult(`Altro (reviewer): ${outcome.text}`);
@@ -1102,7 +1108,7 @@ export default function (pi) {
});
let resp;
for (;;) {
resp = await emitAndWait(ctx, widget);
resp = await emitAndWait(ctx, widget, session);
if (resp.control === "freetext" || resp.control === "back" || resp.control === "exit")
break;
if (!joinOnly || isJoinReviewApproval(resp, meritOptions.map((option) => option.id)))
@@ -1243,7 +1249,7 @@ export default function (pi) {
title,
tables: enriched,
});
const resp = await emitAndWait(ctx, widget);
const resp = await emitAndWait(ctx, widget, session);
if (resp.control === "back") return textResult("Il reviewer vuole tornare indietro.");
if (resp.control === "exit") return textResult("Il reviewer vuole uscire.");
if (resp.control === "freetext")
@@ -1494,7 +1500,7 @@ export default function (pi) {
});
let outcome;
for (;;) {
const resp = await emitAndWait(ctx, widget);
const resp = await emitAndWait(ctx, widget, session);
outcome = resolveConfirmOutcome(resp);
if (outcome.kind !== "unknown") break;
await ctx.ui.notify(gateText(locale, "chooseApproval"), "warning");