Add full shell, replaceable Omics adapter and bilingual interaction

Implement approved specification #32 and tickets #33-#37. Keep host authentication server-verified and pin session interaction language. Compile scoped base selectors for browser compatibility and retain full gutters during CSS pruning.
This commit is contained in:
Codex
2026-09-13 14:26:39 +02:00
parent 2d1b714ebe
commit d8a29bfbdd
207 changed files with 8570 additions and 2164 deletions
+19 -6
View File
@@ -25,6 +25,7 @@ export interface SessionRuntime {
}
export interface RuntimeOptions {
interactionLanguage?: string | null;
provider?: string;
model?: string;
thinking?: string;
@@ -48,6 +49,7 @@ export class PiProcessManager {
private spawnFn: (
sessionId: string, author: string, provider: string | undefined, model: string | undefined,
principal?: PrincipalContext, runtimeConfigPath?: string,
interactionLanguage?: string | null,
) => ChildProcessWithoutNullStreams;
private loadAuthProviders: (agentDir: string) => ReadonlySet<string>;
private modelCatalog: RuntimeModelCatalog;
@@ -67,11 +69,11 @@ export class PiProcessManager {
this.loadAuthProviders = opts?.authProviders
?? ((agentDir) => loadPiAuthProviders({ agentDir }));
if (opts?.spawnFn) {
this.spawnFn = (sessionId, author, provider, model, principal, runtimeConfigPath) =>
this.spawnPi(opts.spawnFn!, sessionId, author, provider, model, principal, runtimeConfigPath);
this.spawnFn = (sessionId, author, provider, model, principal, runtimeConfigPath, language) =>
this.spawnPi(opts.spawnFn!, sessionId, author, provider, model, principal, runtimeConfigPath, language);
} else {
this.spawnFn = (sessionId, author, provider, model, principal, runtimeConfigPath) =>
this.spawnPi(nodeSpawn, sessionId, author, provider, model, principal, runtimeConfigPath);
this.spawnFn = (sessionId, author, provider, model, principal, runtimeConfigPath, language) =>
this.spawnPi(nodeSpawn, sessionId, author, provider, model, principal, runtimeConfigPath, language);
}
}
@@ -85,6 +87,7 @@ export class PiProcessManager {
private spawnPi(
spawnFn: SpawnFn, sessionId: string, author: string, provider: string | undefined,
model: string | undefined, principal?: PrincipalContext, runtimeConfigPath?: string,
interactionLanguage?: string | null,
): ChildProcessWithoutNullStreams {
// This is the final shared boundary for createFor(), spawnFor(), and resume(). Validate
// before auth-provider inspection, then make Pi consume the exact copied bytes rather than
@@ -110,6 +113,9 @@ export class PiProcessManager {
additions: { THT_SESSION: sessionId, THT_AUTHOR: author },
});
env.PI_CODING_AGENT_DIR = agent.agentDir;
// A launch hint only: the gate reads the authoritative manifest before each turn.
delete env.THT_INTERACTION_LANGUAGE;
if (interactionLanguage) env.THT_INTERACTION_LANGUAGE = interactionLanguage;
env.PI_CODING_AGENT_SESSION_DIR = agent.sessionDir;
clearPrincipalEnvironment(env);
if (principal) Object.assign(env, principalEnvironment(principal));
@@ -187,7 +193,9 @@ export class PiProcessManager {
const model = o.model ?? this.cfg.defaults.model;
let child: ChildProcessWithoutNullStreams;
try {
child = this.spawnFn(sessionId, author, provider, model, o.principal, o.runtimeConfig?.path);
child = this.spawnFn(
sessionId, author, provider, model, o.principal, o.runtimeConfig?.path, o.interactionLanguage,
);
} catch (error) {
o.runtimeConfig?.release();
throw error;
@@ -296,8 +304,13 @@ export class PiProcessManager {
}
async resume(sessionId: string, tht: ThtRunner): Promise<SessionRuntime> {
const manifest = await tht.sessionShow(sessionId) as { provider?: string; model?: string; thinking?: string } | null;
const manifest = await tht.sessionShow(sessionId) as {
provider?: string; model?: string; thinking?: string; interaction_language?: string | null;
} | null;
const language = manifest?.interaction_language
?? (await tht.ensureInteractionLanguage(sessionId)).interaction_language;
return this.spawnFor(sessionId, {
interactionLanguage: language,
provider: manifest?.provider,
model: manifest?.model,
thinking: manifest?.thinking,
+24 -1
View File
@@ -13,6 +13,7 @@ import type { MaintenanceBarrier } from "../runtime/maintenance-gate.js";
import { hasPermission, isPrincipalContext, requirePermission } from "../auth/authorization.js";
import { splitCanonicalModelId, type RuntimeModelCatalog } from "../models/runtime-model-catalog.js";
import type { CatalogRepository } from "../catalog/types.js";
import { interactionLanguage } from "../tht/interaction-language.js";
const BOOTSTRAP_FAILURE_MESSAGE =
"Session startup failed. Check configuration and connectivity, then Resume the session.";
@@ -381,8 +382,13 @@ export function sessionRoutes(
app.post("/sessions", async (req, reply) => {
const b = req.body as {
question: string; name?: string; workspace?: string; workspaceId?: string;
provider?: string; model?: string; thinking?: string;
provider?: string; model?: string; thinking?: string; interactionLanguage?: unknown;
};
const language = interactionLanguage(b?.interactionLanguage);
if (!language) return reply.code(400).send({
code: "invalid_interaction_language",
error: "interactionLanguage must be a well-formed BCP-47 language tag",
});
if ((b.provider === undefined) !== (b.model === undefined)
|| (b.provider !== undefined && (typeof b.provider !== "string" || typeof b.model !== "string" || !b.provider || !b.model))) {
return reply.code(400).send({ error: "provider and model must be supplied together" });
@@ -507,6 +513,7 @@ export function sessionRoutes(
({ id } = await runner.sessionNew({
question: b.question, name: b.name, workspaceConfigPath,
workspaceId, workspaceRevision, provider, model, thinking,
interactionLanguage: language,
}));
manifestPersisted = true;
if (revisionLease) {
@@ -520,6 +527,7 @@ export function sessionRoutes(
} catch { return storageFailure(reply); }
const options = {
provider, model, thinking,
interactionLanguage: language,
author: principal.displayName ?? principal.subject,
principal,
question: b.question,
@@ -664,6 +672,13 @@ export function sessionRoutes(
app.post("/sessions/:id/resume", async (req, reply) => {
const id = (req.params as any).id;
const principal = getPrincipal(req);
if (Object.hasOwn(req.body ?? {}, "interactionLanguage")
|| Object.hasOwn(req.body ?? {}, "interaction_language")) {
return reply.code(400).send({
code: "interaction_language_pinned",
error: "Resume uses the session's persisted interaction language; overrides are not accepted",
});
}
return withSessionLifecycle(id, async () => {
let settings: Settings;
let located: LocatedSession | undefined;
@@ -681,6 +696,7 @@ export function sessionRoutes(
const saved = manifest as {
provider?: string; model?: string; thinking?: string;
workspace_id?: string; workspace_revision?: string;
interaction_language?: string | null;
};
const requested = (req.body ?? {}) as { provider?: string; model?: string; thinking?: string };
if ((requested.provider === undefined) !== (requested.model === undefined)
@@ -719,6 +735,12 @@ export function sessionRoutes(
});
}
try { settings = await d.getSettings(principal); } catch { return storageFailure(reply); }
let language = saved.interaction_language;
if (language == null) {
try {
language = (await runner.ensureInteractionLanguage(id, workspaceConfigPath)).interaction_language;
} catch { return reply.code(503).send({ error: RESUME_FAILURE_MESSAGE }); }
}
// This check belongs inside the per-session lock: a preceding cold Resume may have
// installed a running runtime while this request was waiting.
const existing = d.mgr.get(id);
@@ -737,6 +759,7 @@ export function sessionRoutes(
});
const options = {
provider: selected.provider,
interactionLanguage: language,
model: selected.model,
thinking: saved?.thinking ?? settings.thinking,
author: principal.displayName ?? principal.subject,
+10
View File
@@ -0,0 +1,10 @@
/** Validate/canonicalize the tag; available translation catalogs belong to the UI. */
export function interactionLanguage(value: unknown): string | undefined {
if (typeof value !== "string") return undefined;
try {
const [canonical] = Intl.getCanonicalLocales(value);
return canonical;
} catch {
return undefined;
}
}
+9
View File
@@ -59,6 +59,7 @@ export interface SessionRow {
author: string | null;
workspace_id?: string | null;
workspace_revision?: string | null;
interaction_language?: string | null;
archived?: boolean;
}
@@ -482,6 +483,7 @@ export class ThtRunner {
async sessionNew(o: {
question: string;
interactionLanguage?: string;
provider?: string;
model?: string;
thinking?: string;
@@ -497,6 +499,7 @@ export class ThtRunner {
["--provider", o.provider],
["--model", o.model],
["--thinking", o.thinking],
["--interaction-language", o.interactionLanguage],
["--name", o.name],
["--workspace-id", o.workspaceId],
["--workspace-revision", o.workspaceRevision],
@@ -533,6 +536,12 @@ export class ThtRunner {
return this.json<unknown>(["session", "show", id, "--json"], workspace);
}
ensureInteractionLanguage(id: string, workspace?: string) {
return this.json<{ interaction_language: string }>(
["session", "ensure-interaction-language", id, "--json"], workspace,
);
}
sqlPreview(id: string, p: { limit?: number; offset?: number }, workspace?: string) {
// No positional FILE: the harness resolves sql_final.sql from the session
// via _session_sql_file(cfg, session_id), which respects the workspace path.
+2 -2
View File
@@ -36,7 +36,7 @@ test("loop F1: crea sessione → SSE riceve il widget → risponde → il modell
ollamaEnsure: async () => ({ ok: true }),
searchPack: async () => {},
sessionNew: async () => ({ id: "s1" }),
sessionShow: async (_id: string) => ({ id: "s1", provider: undefined, model: undefined, thinking: undefined }),
sessionShow: async (_id: string) => ({ id: "s1", interaction_language: "en", provider: undefined, model: undefined, thinking: undefined }),
sessionList: async () => [],
} as any,
spawnFn: () => spawn("node", [FAKE, SCRIPT]) as any,
@@ -48,7 +48,7 @@ test("loop F1: crea sessione → SSE riceve il widget → risponde → il modell
const created = await fetch(`${base}/sessions`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ workspace: "w", question: "q" }),
body: JSON.stringify({ workspace: "w", question: "q", interactionLanguage: "en" }),
});
expect(created.status).toBe(200);
+23
View File
@@ -190,6 +190,29 @@ test("Pi receives the leased workspace runtime config and releases it on direct
expect(release).toHaveBeenCalledOnce();
});
test("Pi language launch hint comes from the session options and never ambient environment", () => {
const previous = process.env.THT_INTERACTION_LANGUAGE;
process.env.THT_INTERACTION_LANGUAGE = "it";
const environments: NodeJS.ProcessEnv[] = [];
const mgr = new PiProcessManager(loadConfig({}), {
spawnFn: (_command, _args, options) => {
environments.push(options.env);
return recordingChild() as any;
},
});
try {
mgr.createFor("explicit-language", { interactionLanguage: "en" });
mgr.teardown("explicit-language");
mgr.createFor("manifest-resolved-in-gate");
mgr.teardown("manifest-resolved-in-gate");
expect(environments[0].THT_INTERACTION_LANGUAGE).toBe("en");
expect(environments[1]).not.toHaveProperty("THT_INTERACTION_LANGUAGE");
} finally {
if (previous === undefined) delete process.env.THT_INTERACTION_LANGUAGE;
else process.env.THT_INTERACTION_LANGUAGE = previous;
}
});
test("a close-only child event releases its temporary Pi agent snapshot", () => {
const child = recordingChild();
let snapshotDir: string | undefined;
+169 -42
View File
@@ -53,7 +53,11 @@ const defaultWorkspaceRegistry = {
function buildApp(config: Parameters<typeof buildRealApp>[0], deps: Record<string, unknown> = {}) {
const thtRunner = deps.thtRunner
? { qdrantEnsure: async () => ({ ok: true }), ...(deps.thtRunner as object) }
? {
qdrantEnsure: async () => ({ ok: true }),
ensureInteractionLanguage: async () => ({ interaction_language: "en" }),
...(deps.thtRunner as object),
}
: undefined;
return buildRealApp(config, {
workspaceRuntimeSupport: () => true,
@@ -88,6 +92,129 @@ const aliceHeaders = {
"x-thoth-is-admin": "0",
};
test.each([undefined, null, "", "en--US", "en_US", "en\nIGNORE", "en<script>", 42])(
"new sessions reject invalid interaction language %s before persistence", async (interactionLanguage) => {
const app = mutApp({ sessionNew: async () => { throw new Error("must not create"); } });
try {
const response = await app.inject({
method: "POST", url: "/sessions", payload: { question: "Pazienti", interactionLanguage },
});
expect(response.statusCode).toBe(400);
expect(response.json()).toMatchObject({ code: "invalid_interaction_language" });
} finally { await app.close(); }
},
);
test.each([["en", "en"], ["fr-FR", "fr-FR"], ["FR-fr", "fr-FR"]])(
"new session forwards canonical interaction language %s without changing the question", async (language, canonical) => {
let created: any;
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), {
thtRunner: {
sessionNew: async (options: any) => { created = options; return { id: "language" }; },
searchPack: async () => {},
},
readiness: { ensure: async () => ({ ok: true }) },
mgr: {
get: () => undefined, createFor: () => ({ bridge: { onClientEvent: () => {} } }),
configure: async () => {}, start: () => {},
},
getSettings: () => ({ workspace: "default" }),
});
try {
const response = await app.inject({
method: "POST", url: "/sessions", payload: { question: "Pazienti", interactionLanguage: language },
});
expect(response.statusCode).toBe(200);
expect(created).toMatchObject({ question: "Pazienti", interactionLanguage: canonical });
} finally { await app.close(); }
});
test("resume rejects browser interaction language overrides", async () => {
const app = mutApp({ sessionShow: async () => ({ status: "open", interaction_language: "it" }) });
try {
const response = await app.inject({
method: "POST", url: "/sessions/s1/resume", payload: { interactionLanguage: "en" },
});
expect(response.statusCode).toBe(400);
expect(response.json()).toMatchObject({ code: "interaction_language_pinned" });
} finally { await app.close(); }
});
test("resume pins legacy interaction language using the resolved harness workspace", async () => {
let pinnedWorkspace: string | undefined;
let runtime: any;
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), {
thtRunner: {
sessionShow: async () => ({ id: "legacy", status: "closed" }),
ensureInteractionLanguage: async (_id: string, workspace: string) => {
pinnedWorkspace = workspace;
return { interaction_language: "it" };
},
reopenSession: async () => {},
},
readiness: { ensure: async () => ({ ok: true }) },
mgr: {
get: () => undefined,
createFor: (_id: string, options: any) => {
runtime = options;
return { bridge: { onClientEvent: () => {} } };
},
configure: async () => {}, start: () => {},
},
getSettings: () => ({ workspace: "other-browser-workspace" }),
});
try {
const response = await app.inject({ method: "POST", url: "/sessions/legacy/resume" });
expect(response.statusCode).toBe(200);
expect(pinnedWorkspace).toContain("/default.yaml");
expect(runtime).toMatchObject({ interactionLanguage: "it", mode: "resume" });
} finally { await app.close(); }
});
test("resume retains persisted interaction language despite different browser settings", async () => {
let options: any;
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), {
thtRunner: {
sessionShow: async () => ({ id: "saved", status: "closed", interaction_language: "it" }),
ensureInteractionLanguage: async () => { throw new Error("language is already pinned"); },
reopenSession: async () => {},
},
readiness: { ensure: async () => ({ ok: true }) },
mgr: {
get: () => undefined,
createFor: (_id: string, value: any) => {
options = value;
return { bridge: { onClientEvent: () => {} } };
},
configure: async () => {}, start: () => {},
},
getSettings: () => ({ workspace: "english-workspace", locale: "en" }),
});
try {
const response = await app.inject({ method: "POST", url: "/sessions/saved/resume" });
expect(response.statusCode).toBe(200);
expect(options.interactionLanguage).toBe("it");
} finally { await app.close(); }
});
test("resume cannot start Pi if legacy interaction language cannot be persisted", async () => {
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), {
thtRunner: {
sessionShow: async () => ({ id: "legacy", status: "closed" }),
ensureInteractionLanguage: async () => { throw new Error("private storage details"); },
reopenSession: async () => { throw new Error("must not reopen"); },
},
readiness: { ensure: async () => ({ ok: true }) },
mgr: { createFor: () => { throw new Error("must not spawn"); } },
getSettings: () => ({ workspace: "default" }),
});
try {
const response = await app.inject({ method: "POST", url: "/sessions/legacy/resume" });
expect(response.statusCode).toBe(503);
expect(response.body).not.toContain("private storage details");
} finally { await app.close(); }
});
test("upstream requests without a principal fail before a Pi runtime can be created", async () => {
let created = false;
const app = buildApp(loadConfig({ AUTH_MODE: "upstream", THT_HARNESS_DIR: "../harness" }), {
@@ -95,7 +222,7 @@ test("upstream requests without a principal fail before a Pi runtime can be crea
thtRunner: {} as any,
});
const response = await app.inject({ method: "POST", url: "/sessions", payload: { question: "q" } });
const response = await app.inject({ method: "POST", url: "/sessions", payload: { interactionLanguage: "en", question: "q" } });
expect(response.statusCode).toBe(401);
expect(created).toBe(false);
@@ -133,7 +260,7 @@ test("maintenance rejects new and resumed session admission without interrupting
});
const create = await app.inject({
method: "POST", url: "/sessions", headers: aliceHeaders, payload: { question: "q" },
method: "POST", url: "/sessions", headers: aliceHeaders, payload: { interactionLanguage: "en", question: "q" },
});
const resume = await app.inject({ method: "POST", url: "/sessions/open/resume", headers: aliceHeaders });
@@ -154,7 +281,7 @@ test("a durable maintenance marker initializes admission closed after backend re
AUTH_MODE: "upstream", THT_HARNESS_DIR: "../harness", THT_MAINTENANCE_FILE: marker,
}), { thtRunner: {} as any });
const response = await app.inject({
method: "POST", url: "/sessions", headers: aliceHeaders, payload: { question: "q" },
method: "POST", url: "/sessions", headers: aliceHeaders, payload: { interactionLanguage: "en", question: "q" },
});
expect(response.statusCode).toBe(503);
expect(response.json()).toMatchObject({ code: "maintenance" });
@@ -492,7 +619,7 @@ test("new sessions are created through the authenticated principal, not a client
const response = await app.inject({
method: "POST", url: "/sessions", headers: aliceHeaders,
payload: { question: "q", owner: "mallory" },
payload: { interactionLanguage: "en", question: "q", owner: "mallory" },
});
expect(response.statusCode).toBe(200);
@@ -509,7 +636,7 @@ test("new sessions reject the client legacy workspace field unless local legacy
});
const response = await app.inject({
method: "POST", url: "/sessions", payload: { question: "q", workspace: "legacy" },
method: "POST", url: "/sessions", payload: { interactionLanguage: "en", question: "q", workspace: "legacy" },
});
expect(response.statusCode).toBe(409);
@@ -529,7 +656,7 @@ test("explicit local legacy mode permits the unpinned client workspace request",
});
const response = await app.inject({
method: "POST", url: "/sessions", payload: { question: "q", workspace: "legacy" },
method: "POST", url: "/sessions", payload: { interactionLanguage: "en", question: "q", workspace: "legacy" },
});
expect(response.statusCode).toBe(200);
@@ -567,7 +694,7 @@ test("creates a session from the active immutable workspace revision", async ()
await app.inject({
method: "POST", url: "/sessions",
payload: { question: "q", workspaceId: "psd-clinical", provider: "zai", model: "glm-5.2", thinking: "low" },
payload: { interactionLanguage: "en", question: "q", workspaceId: "psd-clinical", provider: "zai", model: "glm-5.2", thinking: "low" },
});
expect(sessionNew).toHaveBeenCalledWith(expect.objectContaining({
@@ -611,7 +738,7 @@ test("hands one Catalog-backed runtime to both retrieval and Pi", async () => {
const response = await app.inject({
method: "POST",
url: "/sessions",
payload: { question: "Which users placed orders?", workspaceId: "default" },
payload: { interactionLanguage: "en", question: "Which users placed orders?", workspaceId: "default" },
});
expect(response.statusCode).toBe(200);
@@ -687,7 +814,7 @@ test("refuses core admission when the workspace preprocessing fingerprint is sta
const response = await app.inject({
method: "POST",
url: "/sessions",
payload: { question: "q", workspaceId: "default" },
payload: { interactionLanguage: "en", question: "q", workspaceId: "default" },
});
expect(response.statusCode).toBe(409);
@@ -739,7 +866,7 @@ test("rejects an SSH-only Catalog binding before persisting or starting a sessio
} as any,
});
const response = await app.inject({ method: "POST", url: "/sessions", payload: { question: "q" } });
const response = await app.inject({ method: "POST", url: "/sessions", payload: { interactionLanguage: "en", question: "q" } });
expect(response.statusCode).toBe(409);
expect(response.json()).toMatchObject({ code: "workspace_not_activatable" });
@@ -779,7 +906,7 @@ test("hands a revision lease to retention only after the session manifest is dur
workspaceRegistry: { acquireSessionRevision } as any,
});
const request = app.inject({ method: "POST", url: "/sessions", payload: { question: "q" } });
const request = app.inject({ method: "POST", url: "/sessions", payload: { interactionLanguage: "en", question: "q" } });
await new Promise((resolve) => setImmediate(resolve));
expect(markPersisted).not.toHaveBeenCalled();
expect(abort).not.toHaveBeenCalled();
@@ -810,7 +937,7 @@ test("creates a session from the configured default workspace revision when work
workspaceRegistry: registry as any,
});
await app.inject({ method: "POST", url: "/sessions", payload: { question: "q" } });
await app.inject({ method: "POST", url: "/sessions", payload: { interactionLanguage: "en", question: "q" } });
expect(registry.read).toHaveBeenCalledWith("psd-clinical");
expect(sessionNew).toHaveBeenCalledWith(expect.objectContaining({
@@ -896,7 +1023,7 @@ test("session lifecycle locates a B session when installation default is A", asy
} as any,
});
expect((await app.inject({ method: "POST", url: "/sessions", payload: {
expect((await app.inject({ method: "POST", url: "/sessions", payload: { interactionLanguage: "en",
question: "B question", workspaceId: "b-workspace", provider: "zai", model: "glm-5.2", thinking: "low",
} })).statusCode).toBe(200);
expect((await app.inject({ method: "GET", url: "/sessions" })).json()).toEqual([
@@ -939,7 +1066,7 @@ test("POST /sessions uses the catalog default with workspace/thinking settings a
],
spawnFn: () => nodeSpawn("node", [FAKE, SCRIPT]) as any,
});
const created = await app.inject({ method: "POST", url: "/sessions", payload: { question: "q" } });
const created = await app.inject({ method: "POST", url: "/sessions", payload: { interactionLanguage: "en", question: "q" } });
expect(created.json()).toEqual({ id: "s1" });
expect(sessionNewArg.workspaceConfigPath).toContain(`/snapshots/${"e".repeat(40)}/w.yaml`);
expect(sessionNewArg.provider).toBe("zai");
@@ -1000,11 +1127,11 @@ test("POST /sessions stops the user's previous open Pi runtime before creating a
getSettings: () => ({ workspace: "local" }) as any,
});
expect((await app.inject({ method: "POST", url: "/sessions", payload: { question: "one" } })).statusCode)
expect((await app.inject({ method: "POST", url: "/sessions", payload: { interactionLanguage: "en", question: "one" } })).statusCode)
.toBe(200);
order.length = 0;
const second = await app.inject({ method: "POST", url: "/sessions", payload: { question: "two" } });
const second = await app.inject({ method: "POST", url: "/sessions", payload: { interactionLanguage: "en", question: "two" } });
expect(second.statusCode).toBe(200);
expect(second.json()).toEqual({ id: "s2" });
@@ -1025,7 +1152,7 @@ test("POST /sessions refuses to create a session when the local DWH precheck fai
readiness: { ensure: async () => ({ ok: true }) } as any,
getSettings: () => ({ workspace: "w" }) as any,
});
const res = await app.inject({ method: "POST", url: "/sessions", payload: { question: "q" } });
const res = await app.inject({ method: "POST", url: "/sessions", payload: { interactionLanguage: "en", question: "q" } });
expect(res.statusCode).toBe(503);
expect(res.json()).toMatchObject({ code: "dwh_unreachable" });
expect(pinged).toBe(1);
@@ -1046,7 +1173,7 @@ test("POST /sessions proceeds past a passing DWH precheck", async () => {
getSettings: () => ({ workspace: "w" }) as any,
spawnFn: () => nodeSpawn("node", [FAKE, SCRIPT]) as any,
});
const res = await app.inject({ method: "POST", url: "/sessions", payload: { question: "q" } });
const res = await app.inject({ method: "POST", url: "/sessions", payload: { interactionLanguage: "en", question: "q" } });
expect(res.statusCode).toBe(200);
expect(pinged).toBe(1);
expect(created).toBe(1);
@@ -1065,7 +1192,7 @@ test("POST /sessions skips the DWH precheck when the flag is off (default)", asy
getSettings: () => ({ workspace: "w" }) as any,
spawnFn: () => nodeSpawn("node", [FAKE, SCRIPT]) as any,
});
const res = await app.inject({ method: "POST", url: "/sessions", payload: { question: "q" } });
const res = await app.inject({ method: "POST", url: "/sessions", payload: { interactionLanguage: "en", question: "q" } });
expect(res.statusCode).toBe(200);
expect(pinged).toBe(0); // probe never runs without the flag
});
@@ -1099,7 +1226,7 @@ test("POST /sessions configura Pi con il thinking globale selezionato", async ()
],
});
await app.inject({ method: "POST", url: "/sessions", payload: { question: "q" } });
await app.inject({ method: "POST", url: "/sessions", payload: { interactionLanguage: "en", question: "q" } });
await new Promise((resolve) => setImmediate(resolve));
expect(configured.thinking).toBe("high");
@@ -1427,7 +1554,7 @@ test("resuming a different session stops the user's previous Pi runtime", async
readiness: { ensure: async () => ({ ok: true }) } as any,
getSettings: () => ({ workspace: "local" }) as any,
});
expect((await app.inject({ method: "POST", url: "/sessions", payload: { question: "one" } })).statusCode)
expect((await app.inject({ method: "POST", url: "/sessions", payload: { interactionLanguage: "en", question: "one" } })).statusCode)
.toBe(200);
const resumed = await app.inject({ method: "POST", url: "/sessions/s2/resume" });
@@ -1815,13 +1942,13 @@ test.each([
getSettings: () => ({ workspace: "local" }) as any,
});
await app.inject({ method: "POST", url: "/sessions", payload: { question: "old" } });
await app.inject({ method: "POST", url: "/sessions", payload: { interactionLanguage: "en", question: "old" } });
if (lifecycle === "close") {
await app.inject({ method: "POST", url: "/sessions/s1/close" });
await app.inject({ method: "POST", url: "/sessions/s1/resume" });
} else {
await app.inject({ method: "DELETE", url: "/sessions/s1" });
await app.inject({ method: "POST", url: "/sessions", payload: { question: "replacement" } });
await app.inject({ method: "POST", url: "/sessions", payload: { interactionLanguage: "en", question: "replacement" } });
}
await new Promise((resolve) => setImmediate(resolve));
expect(current).toBe(replacement);
@@ -1885,7 +2012,7 @@ test.each(["resolve", "reject"] as const)(
getSettings: () => ({ workspace: "local" }) as any,
});
await app.inject({ method: "POST", url: "/sessions", payload: { question: "old" } });
await app.inject({ method: "POST", url: "/sessions", payload: { interactionLanguage: "en", question: "old" } });
await app.inject({ method: "DELETE", url: "/sessions/s1" });
published.length = 0;
@@ -2077,7 +2204,7 @@ test("Close suppresses a bootstrap that settles while close persistence is pendi
getSettings: () => ({ workspace: "local" }) as any,
});
await app.inject({ method: "POST", url: "/sessions", payload: { question: "q" } });
await app.inject({ method: "POST", url: "/sessions", payload: { interactionLanguage: "en", question: "q" } });
const closeResponse = app.inject({ method: "POST", url: "/sessions/s1/close" });
await closeStarted;
published.length = 0;
@@ -2151,7 +2278,7 @@ test("bootstrap failure persists once and keeps Resume serialized behind that pe
getSettings: () => ({ workspace: "local" }) as any,
});
await app.inject({ method: "POST", url: "/sessions", payload: { question: "q" } });
await app.inject({ method: "POST", url: "/sessions", payload: { interactionLanguage: "en", question: "q" } });
oldConfigure.reject(new Error("configure failed"));
await failureStarted;
const resumeResponse = app.inject({ method: "POST", url: "/sessions/s1/resume" })
@@ -2274,7 +2401,7 @@ test("a replaced runtime cannot publish or fail the newly resumed session", asyn
readiness: { ensure: async () => ({ ok: true }) } as any,
getSettings: () => ({ workspace: "local" }) as any,
});
await app.inject({ method: "POST", url: "/sessions", payload: { question: "q" } });
await app.inject({ method: "POST", url: "/sessions", payload: { interactionLanguage: "en", question: "q" } });
await new Promise((resolve) => setImmediate(resolve));
oldBridge.setState("idle");
@@ -2334,7 +2461,7 @@ test("a deleted runtime cannot repopulate or fail the forgotten session", async
readiness: { ensure: async () => ({ ok: true }) } as any,
getSettings: () => ({ workspace: "local" }) as any,
});
await app.inject({ method: "POST", url: "/sessions", payload: { question: "q" } });
await app.inject({ method: "POST", url: "/sessions", payload: { interactionLanguage: "en", question: "q" } });
await new Promise((resolve) => setImmediate(resolve));
const response = await app.inject({ method: "DELETE", url: "/sessions/s1" });
@@ -2379,7 +2506,7 @@ test("an unexpectedly exited runtime publishes its terminal sequence then releas
readiness: { ensure: async () => ({ ok: true }) } as any,
getSettings: () => ({ workspace: "local" }) as any,
});
await app.inject({ method: "POST", url: "/sessions", payload: { question: "q" } });
await app.inject({ method: "POST", url: "/sessions", payload: { interactionLanguage: "en", question: "q" } });
await new Promise((resolve) => setImmediate(resolve));
published.length = 0;
@@ -2430,7 +2557,7 @@ test("agent_end releases the Pi runtime after the session was finalized", async
readiness: { ensure: async () => ({ ok: true }) } as any,
getSettings: () => ({ workspace: "local" }) as any,
});
await app.inject({ method: "POST", url: "/sessions", payload: { question: "q" } });
await app.inject({ method: "POST", url: "/sessions", payload: { interactionLanguage: "en", question: "q" } });
await new Promise((resolve) => setImmediate(resolve));
bridge.emitClientEvent({ type: "system_event", event: "agent_end" });
@@ -2502,7 +2629,7 @@ test("POST /sessions/:id/response senza gate pendente risponde 409 (risposta sta
getSettings: () => ({ workspace: "w" }),
spawnFn: () => nodeSpawn("node", [FAKE, SCRIPT]) as any,
});
await app.inject({ method: "POST", url: "/sessions", payload: { question: "q" } });
await app.inject({ method: "POST", url: "/sessions", payload: { interactionLanguage: "en", question: "q" } });
// The fake Pi never emitted a ui_request: the bridge has no pending descriptor, so a
// response (stale UI, double submit) must be rejected instead of forwarded to Pi.
const res = await app.inject({ method: "POST", url: "/sessions/s1/response",
@@ -2643,7 +2770,7 @@ test("POST /sessions readiness failure returns one fixed public message without
getSettings: () => ({ workspace: "psd" }) as any,
spawnFn: () => nodeSpawn("node", [FAKE, SCRIPT]) as any,
});
const res = await app.inject({ method: "POST", url: "/sessions", payload: { question: "q" } });
const res = await app.inject({ method: "POST", url: "/sessions", payload: { interactionLanguage: "en", question: "q" } });
expect(res.statusCode).toBe(503);
expect(res.json()).toEqual({
error: "Session services are not ready. Check configuration and connectivity, then try again.",
@@ -2664,7 +2791,7 @@ test.each(["semantic_index_incompatible", "workspace_not_activatable"] as const)
});
const response = await app.inject({
method: "POST", url: "/sessions", payload: { question: "q" },
method: "POST", url: "/sessions", payload: { interactionLanguage: "en", question: "q" },
});
expect(response.statusCode).toBe(503);
@@ -2687,7 +2814,7 @@ test("POST /sessions returns storage 503 before creating a Pi runtime when sessi
mgr: { createFor: () => { piCreated = true; throw new Error("must not spawn"); } } as any,
});
const response = await app.inject({ method: "POST", url: "/sessions", payload: { question: "q" } });
const response = await app.inject({ method: "POST", url: "/sessions", payload: { interactionLanguage: "en", question: "q" } });
expect(response.statusCode).toBe(503);
expect(response.json()).toEqual({ error: "session storage is unavailable" });
@@ -2707,7 +2834,7 @@ test("POST /sessions proceeds when ollamaEnsure succeeds", async () => {
getSettings: () => ({ workspace: "psd" }) as any,
spawnFn: () => nodeSpawn("node", [FAKE, SCRIPT]) as any,
});
const res = await app.inject({ method: "POST", url: "/sessions", payload: { question: "q" } });
const res = await app.inject({ method: "POST", url: "/sessions", payload: { interactionLanguage: "en", question: "q" } });
expect(res.json()).toEqual({ id: "s1" });
expect(qdrantEnsure).toHaveBeenCalledWith(operationalWorkspace("psd"), 60, "self_heal");
expect(ensureWs).toContain(`/snapshots/${"e".repeat(40)}/psd.yaml`);
@@ -2740,7 +2867,7 @@ test("POST /sessions rejects a stale requested model without silently using the
const res = await app.inject({
method: "POST",
url: "/sessions",
payload: { question: "q", provider: "deepseek", model: "deepseek-v4-pro" },
payload: { interactionLanguage: "en", question: "q", provider: "deepseek", model: "deepseek-v4-pro" },
});
expect(res.statusCode).toBe(503);
@@ -2775,7 +2902,7 @@ test("POST /sessions marks a persisted session failed when runtime construction
],
});
const res = await app.inject({ method: "POST", url: "/sessions", payload: { question: "q" } });
const res = await app.inject({ method: "POST", url: "/sessions", payload: { interactionLanguage: "en", question: "q" } });
expect(res.statusCode).toBe(503);
expect(res.json()).toEqual({
@@ -2868,7 +2995,7 @@ test.each([
try {
const response = flow === "new"
? await app.inject({ method: "POST", url: "/sessions", payload: { question: "q" } })
? await app.inject({ method: "POST", url: "/sessions", payload: { interactionLanguage: "en", question: "q" } })
: await app.inject({ method: "POST", url: `/sessions/${sessionId}/resume` });
const logs = consoleError.mock.calls.flat().map(String).join(" ");
@@ -2974,7 +3101,7 @@ test("POST /sessions returns after bridge attachment but starts only after retri
getSettings: () => ({ workspace: "psd" }) as any,
});
const res = await app.inject({ method: "POST", url: "/sessions", payload: { question: "q" } });
const res = await app.inject({ method: "POST", url: "/sessions", payload: { interactionLanguage: "en", question: "q" } });
expect(res.json()).toEqual({ id: "s-early" });
expect(bridgeAttached).toBe(true);
expect(started).toBe(false);
@@ -3021,7 +3148,7 @@ test("POST /sessions bootstrap failure emits only a fixed recovery message", asy
const response = await app.inject({
method: "POST",
url: "/sessions",
payload: { question: "q" },
payload: { interactionLanguage: "en", question: "q" },
});
await new Promise((resolve) => setImmediate(resolve));
@@ -3117,7 +3244,7 @@ test.each([
})),
} as any,
});
const response = await app.inject({ method: "POST", url: "/sessions", payload: { question: "q" } });
const response = await app.inject({ method: "POST", url: "/sessions", payload: { interactionLanguage: "en", question: "q" } });
expect(response.statusCode).toBe(expectedStatus);
expect(ensure).toHaveBeenCalledTimes(reachesReadiness ? 1 : 0);
+13
View File
@@ -34,6 +34,19 @@ test("sessionNew parses id from JSON", async () => {
expect(await r.sessionNew({ question: "q" })).toEqual({ id: "2026-06-27-100000-x" });
});
test("session language uses public per-command CLI flags and the selected config", async () => {
const runner = new ThtRunner({ thtBin: "tht", harnessDir: "/h", configPath: "config/tht.yaml" });
(spawn as any).mockClear();
await runner.sessionNew({ question: "Pazienti", interactionLanguage: "en" });
expect((spawn as any).mock.calls[0][1]).toEqual([
"session", "new", "Pazienti", "--interaction-language", "en", "--json", "-c", "config/tht.yaml",
]);
await runner.ensureInteractionLanguage("s1");
expect((spawn as any).mock.calls[1][1]).toEqual([
"session", "ensure-interaction-language", "s1", "--json", "-c", "config/tht.yaml",
]);
});
test("searchPack persists retrieval context with session and workspace", async () => {
const calls: any[] = [];
const r = new ThtRunner({ thtBin: "tht", harnessDir: "/h", configPath: "config/tht.yaml" });