From d8a29bfbdd796c6edacc67c84ee325fa5d198252 Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 13 Sep 2026 14:26:39 +0200 Subject: [PATCH] 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. --- AGENTS.md | 6 +- CONTEXT.md | 29 + PROJECT_STATE.md | 14 +- backend/src/pi/pi-process-manager.ts | 25 +- backend/src/routes/sessions.ts | 25 +- backend/src/tht/interaction-language.ts | 10 + backend/src/tht/tht-runner.ts | 9 + backend/test/e2e-f1.test.ts | 4 +- backend/test/pi-process-manager.test.ts | 23 + backend/test/routes-sessions.test.ts | 211 ++- backend/test/tht-runner.test.ts | 13 + docker/smoke/frontend-smoke.sh | 22 +- ...ll-modes-and-replaceable-portal-adapter.md | 101 ++ ...ocale-from-session-interaction-language.md | 51 + docs/contracts/portal-shell-adapter-v1.md | 110 ++ docs/operations/shell-and-localization.md | 186 +++ ...09-13-full-shell-and-portal-integration.md | 91 ++ docs/plans/2026-09-13-full-shell-spec.md | 99 ++ .../2026-09-13-full-shell-implementation.md | 129 ++ .../2026-09-13-shell-simplification-review.md | 195 +++ frontend/package-lock.json | 7 + frontend/package.json | 3 + frontend/postcss.config.js | 11 +- frontend/scripts/check-i18n.mjs | 56 + frontend/scripts/scoped-base.mjs | 27 + frontend/scripts/scoped-base.test.mjs | 35 + frontend/src/App.test.tsx | 19 +- frontend/src/App.tsx | 3 +- frontend/src/api/runtime-config.test.ts | 19 +- frontend/src/api/runtime-config.ts | 24 + frontend/src/api/sessions.test.ts | 27 + frontend/src/api/sessions.ts | 4 +- frontend/src/api/types.ts | 2 + frontend/src/auth/AuthGate.test.tsx | 69 +- frontend/src/auth/AuthGate.tsx | 167 ++- frontend/src/auth/LoginPage.tsx | 38 +- frontend/src/auth/authState.ts | 16 + frontend/src/auth/checkAccess.ts | 16 + .../src/components/ErrorBoundary.test.tsx | 12 +- frontend/src/components/ErrorBoundary.tsx | 16 +- frontend/src/components/ui/dialog.tsx | 8 +- frontend/src/components/ui/sonner.tsx | 4 + frontend/src/hooks/useUnsavedDraft.ts | 18 + frontend/src/i18n/index.test.tsx | 23 + frontend/src/i18n/index.tsx | 73 + frontend/src/i18n/locales/it-admin.ts | 1230 +++++++++++++++++ frontend/src/i18n/locales/it-core.ts | 138 ++ frontend/src/i18n/locales/it-gate.ts | 24 + frontend/src/i18n/locales/it-viewers.ts | 48 + frontend/src/i18n/locales/it-workflow.ts | 278 ++++ frontend/src/index.css | 124 +- frontend/src/shell/AppShell.auth.test.tsx | 17 +- .../src/shell/AppShell.notifications.test.tsx | 36 +- frontend/src/shell/AppShell.tsx | 195 +-- frontend/src/shell/ArchiveNavigation.tsx | 8 +- frontend/src/shell/CentralStatus.tsx | 4 +- .../src/shell/DatabaseManagementPage.test.tsx | 17 + frontend/src/shell/DatabaseManagementPage.tsx | 309 ++--- frontend/src/shell/DeleteConfirmDialog.tsx | 12 +- frontend/src/shell/ElapsedTimer.tsx | 6 +- .../src/shell/EvidenceManagementPage.test.tsx | 18 +- frontend/src/shell/EvidenceManagementPage.tsx | 108 +- frontend/src/shell/EvidenceSourceReview.tsx | 53 +- .../src/shell/MemoryManagementPage.test.tsx | 18 +- frontend/src/shell/MemoryManagementPage.tsx | 164 +-- .../src/shell/ModelActivityPanel.test.tsx | 15 + frontend/src/shell/ModelActivityPanel.tsx | 20 +- frontend/src/shell/NavSessions.test.tsx | 17 +- frontend/src/shell/NavSessions.tsx | 12 +- frontend/src/shell/PiManagement.test.tsx | 17 +- frontend/src/shell/PiManagement.tsx | 192 +-- frontend/src/shell/RenameDialog.tsx | 14 +- .../src/shell/SessionDocumentsPanel.test.tsx | 22 +- frontend/src/shell/SessionDocumentsPanel.tsx | 36 +- frontend/src/shell/SessionMenu.tsx | 20 +- frontend/src/shell/SteerInput.test.tsx | 53 +- frontend/src/shell/SteerInput.tsx | 58 +- frontend/src/shell/StopConfirmDialog.tsx | 11 +- frontend/src/shell/WidgetHost.i18n.test.tsx | 25 + frontend/src/shell/WidgetHost.test.tsx | 62 +- frontend/src/shell/WidgetHost.tsx | 17 +- frontend/src/shell/WorkAreaPanel.tsx | 8 +- frontend/src/shell/WorkflowBar.test.tsx | 18 +- frontend/src/shell/WorkflowBar.tsx | 35 +- frontend/src/shell/WorkingContextShelf.tsx | 34 +- frontend/src/shell/WorkspaceManager.test.tsx | 16 + frontend/src/shell/WorkspaceManager.tsx | 170 ++- .../WorkspacePreprocessingControl.test.tsx | 13 +- .../shell/WorkspacePreprocessingControl.tsx | 88 +- .../administration/WorkspacePreparation.tsx | 20 +- .../database-management/CatalogSyncDrawer.tsx | 102 +- .../database-management/DatabaseColumns.tsx | 230 +-- .../database-management/DatabaseForm.tsx | 202 ++- .../database-management/DatabaseGrid.tsx | 256 ++-- .../DatabaseRelationships.tsx | 236 ++-- .../database-management/DatabaseSyncMenu.tsx | 10 +- .../database-management/DatabaseTables.tsx | 338 ++--- .../DescriptionGenerationDrawer.tsx | 127 +- .../FleetActionSelector.tsx | 34 +- .../FleetActionSelector.typography.test.ts | 2 +- .../database-management/FleetLedgerShell.tsx | 30 +- .../MetadataGenerationModelSelector.tsx | 22 +- .../SensitiveDataReviewDrawer.tsx | 59 +- .../SensitivityAnalysisHistoryDrawer.tsx | 141 +- .../src/shell/database-management/messages.ts | 28 + .../src/shell/database-management/model.ts | 11 +- .../database-management/useAdminGridLocale.ts | 19 + frontend/src/shell/f1-loop.test.tsx | 3 + .../src/shell/host/AppShell.host.test.tsx | 211 +++ frontend/src/shell/host/FullHeader.tsx | 70 + .../src/shell/host/OmicsPortalAdapter.test.ts | 76 + frontend/src/shell/host/OmicsPortalAdapter.ts | 31 + frontend/src/shell/host/PortalAdapter.ts | 9 + .../shell/host/SessionNotification.test.tsx | 17 + .../src/shell/host/SessionNotification.tsx | 46 + frontend/src/shell/host/ShellProvider.tsx | 110 ++ frontend/src/shell/host/rememberedSession.ts | 18 + frontend/src/stream/useSessionStream.test.tsx | 42 +- frontend/src/stream/useSessionStream.ts | 30 +- frontend/src/viewers/ArtifactView.tsx | 4 +- frontend/src/viewers/CtePlanViewer.tsx | 38 +- frontend/src/viewers/CteResultViewer.tsx | 8 +- frontend/src/viewers/MarkdownView.tsx | 17 +- frontend/src/viewers/PhaseSummaryViewer.tsx | 13 +- frontend/src/viewers/PreviewGrid.test.tsx | 13 +- frontend/src/viewers/PreviewGrid.tsx | 22 +- frontend/src/viewers/SchemaLinkingViewer.tsx | 26 +- frontend/src/viewers/SqlViewer.test.tsx | 4 +- frontend/src/viewers/SqlViewer.tsx | 15 +- frontend/src/viewers/artifactV2.ts | 1 + frontend/src/viewers/i18n.test.tsx | 18 + frontend/src/viewers/mermaid.ts | 19 +- .../src/widgets/ArchiveRepairWidget.test.tsx | 18 +- frontend/src/widgets/ArchiveRepairWidget.tsx | 39 +- frontend/src/widgets/ArtifactGateWidget.tsx | 6 +- frontend/src/widgets/FallbackWidget.test.tsx | 27 + frontend/src/widgets/FallbackWidget.tsx | 12 +- frontend/src/widgets/FreetextWidget.test.tsx | 23 + frontend/src/widgets/FreetextWidget.tsx | 14 +- frontend/src/widgets/GateArtifactBody.tsx | 4 +- frontend/src/widgets/JoinReviewWidget.tsx | 11 +- .../src/widgets/MemoryReviewWidget.test.tsx | 79 +- frontend/src/widgets/MemoryReviewWidget.tsx | 62 +- .../src/widgets/MultiselectWidget.test.tsx | 47 +- frontend/src/widgets/MultiselectWidget.tsx | 16 +- .../src/widgets/ReservedControls.test.tsx | 35 + frontend/src/widgets/ReservedControls.tsx | 22 +- frontend/src/widgets/SchemaColumnsDialog.tsx | 8 +- .../widgets/SchemaLinkingGateWidget.test.tsx | 32 + .../src/widgets/SchemaLinkingGateWidget.tsx | 16 +- frontend/src/widgets/SelectWidget.tsx | 5 +- frontend/src/widgets/WidgetDrafts.test.tsx | 60 + frontend/src/widgets/localize.ts | 16 + .../gate/__tests__/archive_repair.test.js | 3 + .../gate/__tests__/builders.test.js | 6 +- .../extensions/gate/__tests__/enrich.test.js | 2 +- .../__tests__/gate_confirm_autoclose.test.js | 4 +- .../__tests__/gate_confirm_cte_v2.test.js | 4 +- .../__tests__/gate_datamart_choice.test.js | 2 + .../gate_interaction_language.test.js | 104 ++ .../gate/__tests__/gate_join_review.test.js | 2 + .../__tests__/gate_memory_promote.test.js | 3 + .../__tests__/gate_memory_selection.test.js | 18 +- .../__tests__/gate_promote_autoclose.test.js | 2 +- .../__tests__/gate_provided_session.test.js | 8 +- .../__tests__/gate_resume_kickoff.test.js | 10 + .../gate/__tests__/gate_roundtrip.test.js | 2 +- .../gate_workflow_observable_contract.test.js | 6 +- harness/.pi/extensions/gate/core/builders.js | 21 +- harness/.pi/extensions/gate/core/enrich.js | 6 +- harness/.pi/extensions/gate/core/locale.js | 41 + harness/.pi/extensions/gate/memory/index.js | 33 +- harness/.pi/extensions/gate/memory/repair.js | 9 +- harness/.pi/extensions/tht-gate.js | 96 +- harness/.pi/prompts/nuova-domanda.md | 3 + harness/.pi/prompts/riprendi-sessione.md | 3 + harness/.pi/skills/tht-sessione/SKILL.md | 23 +- .../skills/tht-sessione/projection.md.tmpl | 23 +- harness/.pi/skills/tht-sessione/rewriting.md | 6 +- .../tests/fixtures/approved_cli_surface.json | 3 +- harness/tests/test_cli_surface.py | 4 +- harness/tests/test_pi_skill_projection.py | 3 +- .../tests/test_postgres_session_repository.py | 63 + .../test_session_interaction_language.py | 125 ++ harness/tht/cli/session_cmd.py | 53 +- harness/tht/config.py | 2 +- harness/tht/session/filesystem_repository.py | 13 + harness/tht/session/language.py | 23 + harness/tht/session/models.py | 10 +- harness/tht/session/postgres_repository.py | 25 + harness/tht/session/repository.py | 4 + harness/tht/session/store.py | 17 +- mkdocs.yml | 7 + tools/tht/README.md | 94 ++ .../tht/cmd/tht/installation_generate_test.go | 77 ++ tools/tht/cmd/tht/main.go | 38 + tools/tht/cmd/tht/main_test.go | 4 + tools/tht/internal/config/installation.go | 12 +- .../config/installation_shell_test.go | 113 ++ tools/tht/internal/config/shell.go | 90 ++ .../internal/modelprojection/projection.go | 72 +- .../projection_permissions_unix_test.go | 88 ++ .../modelprojection/projection_test.go | 7 +- .../modelprojection/shell_projection_test.go | 139 ++ tools/tht/internal/setup/files.go | 17 + tools/tht/internal/setup/files_shell_test.go | 78 ++ tools/tht/internal/setup/request.go | 3 + 207 files changed, 8570 insertions(+), 2164 deletions(-) create mode 100644 backend/src/tht/interaction-language.ts create mode 100644 docs/adr/0021-separate-shell-modes-and-replaceable-portal-adapter.md create mode 100644 docs/adr/0022-separate-ui-locale-from-session-interaction-language.md create mode 100644 docs/contracts/portal-shell-adapter-v1.md create mode 100644 docs/operations/shell-and-localization.md create mode 100644 docs/plans/2026-09-13-full-shell-and-portal-integration.md create mode 100644 docs/plans/2026-09-13-full-shell-spec.md create mode 100644 docs/reports/2026-09-13-full-shell-implementation.md create mode 100644 docs/reports/2026-09-13-shell-simplification-review.md create mode 100644 frontend/scripts/check-i18n.mjs create mode 100644 frontend/scripts/scoped-base.mjs create mode 100644 frontend/scripts/scoped-base.test.mjs create mode 100644 frontend/src/auth/checkAccess.ts create mode 100644 frontend/src/hooks/useUnsavedDraft.ts create mode 100644 frontend/src/i18n/index.test.tsx create mode 100644 frontend/src/i18n/index.tsx create mode 100644 frontend/src/i18n/locales/it-admin.ts create mode 100644 frontend/src/i18n/locales/it-core.ts create mode 100644 frontend/src/i18n/locales/it-gate.ts create mode 100644 frontend/src/i18n/locales/it-viewers.ts create mode 100644 frontend/src/i18n/locales/it-workflow.ts create mode 100644 frontend/src/shell/WidgetHost.i18n.test.tsx create mode 100644 frontend/src/shell/database-management/messages.ts create mode 100644 frontend/src/shell/database-management/useAdminGridLocale.ts create mode 100644 frontend/src/shell/host/AppShell.host.test.tsx create mode 100644 frontend/src/shell/host/FullHeader.tsx create mode 100644 frontend/src/shell/host/OmicsPortalAdapter.test.ts create mode 100644 frontend/src/shell/host/OmicsPortalAdapter.ts create mode 100644 frontend/src/shell/host/PortalAdapter.ts create mode 100644 frontend/src/shell/host/SessionNotification.test.tsx create mode 100644 frontend/src/shell/host/SessionNotification.tsx create mode 100644 frontend/src/shell/host/ShellProvider.tsx create mode 100644 frontend/src/shell/host/rememberedSession.ts create mode 100644 frontend/src/viewers/i18n.test.tsx create mode 100644 frontend/src/widgets/FallbackWidget.test.tsx create mode 100644 frontend/src/widgets/WidgetDrafts.test.tsx create mode 100644 frontend/src/widgets/localize.ts create mode 100644 harness/.pi/extensions/gate/__tests__/gate_interaction_language.test.js create mode 100644 harness/.pi/extensions/gate/core/locale.js create mode 100644 harness/tests/test_session_interaction_language.py create mode 100644 harness/tht/session/language.py create mode 100644 tools/tht/README.md create mode 100644 tools/tht/cmd/tht/installation_generate_test.go create mode 100644 tools/tht/internal/config/installation_shell_test.go create mode 100644 tools/tht/internal/config/shell.go create mode 100644 tools/tht/internal/modelprojection/projection_permissions_unix_test.go create mode 100644 tools/tht/internal/modelprojection/shell_projection_test.go create mode 100644 tools/tht/internal/setup/files_shell_test.go diff --git a/AGENTS.md b/AGENTS.md index 8984399c..be29bc0b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -98,8 +98,10 @@ frontend (React/SSE) → backend (Fastify) → pi --mode rpc → tht/harness → - **`tht`'s `-c`/`--config` is a PER-COMMAND option** — it must follow the subcommand, never precede it (`ThtRunner.buildArgv` enforces this; prepending caused live 500s). - **`--json` output must be pristine** (only valid JSON on stdout) — used as a machine contract. -- **UI strings are English; document *content* stays the workspace language** (Italian for - `psd`) because it's the real data. Only chrome/labels are English. +- **Localization:** deterministic UI uses the EN/IT catalogs with English fallback; + model interaction uses the session manifest's immutable `interaction_language`. + Workspace content, SQL, and identifiers remain unchanged. For shell modes, portal + integration, or translations, read `docs/operations/shell-and-localization.md`. - **Workspace schema v4** defines workspace identity and optional Evidence only. PostgreSQL Metadata Catalog owns database identity, binding, schema, descriptions, sensitivity, and relationships; embedding/model facts come from the installation catalog. The legacy `harness/workspaces/*.yaml` runtime snapshots still use diff --git a/CONTEXT.md b/CONTEXT.md index 826e80cf..bf82e195 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -595,6 +595,35 @@ essere ripristinabile con refresh e cronologia e non contiene valori transitori un portale host. Conserva la propria gerarchia funzionale, ma deve rispettare la geometria, l'autenticazione e le regole responsive del portale host. +**Full Thoth Shell** — L'esperienza Thoth autonoma che possiede il proprio header e il proprio +layout di pagina. Non replica la navigazione amministrativa del portale host e non dipende dal suo +template visuale. + +**Shell mode** — La scelta di installazione fra `embedded` e `full`. Determina chi possiede il +chrome globale, i comandi di identità e le integrazioni visuali, ma non cambia il workflow o la +persistenza delle sessioni. + +**Fullscreen state** — Lo stato temporaneo in cui il documento applicativo occupa il fullscreen +del browser. È distinto da `Shell mode`: una Full Thoth Shell può essere aperta senza fullscreen; +il passaggio è attivato da un comando esplicito e può essere annullato con la stessa azione o con +il comando nativo del browser. + +**Portal Shell Adapter** — Il confine sostituibile che traduce lo stato e i comandi del chrome di +un portale host nel modello semantico usato da Thoth. L'adapter non possiede autorizzazione, +sessioni di workflow o contenuti del modello. + +**Host Shell State** — Il minimo stato visuale fornito dal portale host: locale UI, tema e stato +fullscreen. In una Embedded Thoth Shell è la fonte autorevole per queste preferenze; +non include identità, token o stato di autenticazione, che restano responsabilità dell'accesso. + +**UI locale** — La lingua delle label, dei messaggi, dei tooltip, degli stati e delle istruzioni +non generate dal modello nell'interfaccia Thoth. È distinta dalla lingua dei contenuti di un +workspace. + +**Interaction language** — La lingua in cui il modello presenta domande, spiegazioni e proposte +al revisore durante una sessione. Viene fissata alla creazione della sessione e rimane invariata +durante una ripresa, anche se la UI locale corrente cambia. + **Administrative Page Family** — L'insieme delle cinque Administration Page che condividono shell, navigazione, tipografia e regole responsive, pur mantenendo contenuti e operazioni specifici: Workspace, Evidence, Memory, Database e Pi. diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index be947785..6c6d3a95 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -1,6 +1,6 @@ # ThothII — Project State -Last updated: 2026-09-12. +Last updated: 2026-09-13. This file is the short operational snapshot. Stable commands and the architecture mental model live in `AGENTS.md`; current design and runtime contracts live under `docs/architecture/`, @@ -12,6 +12,18 @@ Authentik, internal catalog/embedding services, and the PSD workspace repository `docs/operations/server-upgrade-gitea-workspace-v2.md`. Treat its operator gates and rollback requirements as mandatory; do not replace the running server stack in place. +## Full/embedded shell and bilingual interface + +Full/embedded shell, EN/IT UI, immutable session interaction language, dark theme, +fullscreen and full-mode logout are implemented. The local Mac descriptor explicitly +sets `shell.mode: full` and `shell.defaultLocale: en`; the native `tht` and local +core/frontend images were updated on 2026-09-13. Omics remains embedded and server +verified, through the replaceable presentation-only PortalAdapter. Its changes are +in local Omics commit `95154e1`; no production deployment or push was performed. +See `docs/operations/shell-and-localization.md` for integration and installation +instructions and `docs/reports/2026-09-13-full-shell-implementation.md` for tests, +independent reviews, local browser checks and rollback details. + ## Current product shape Gitea #28–#31 follow-up is implemented in this checkout: Workspace's four tabs, diff --git a/backend/src/pi/pi-process-manager.ts b/backend/src/pi/pi-process-manager.ts index 5712bcd6..ed370e4d 100644 --- a/backend/src/pi/pi-process-manager.ts +++ b/backend/src/pi/pi-process-manager.ts @@ -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; 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 { - 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, diff --git a/backend/src/routes/sessions.ts b/backend/src/routes/sessions.ts index 918c9c05..a9f3a224 100644 --- a/backend/src/routes/sessions.ts +++ b/backend/src/routes/sessions.ts @@ -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, diff --git a/backend/src/tht/interaction-language.ts b/backend/src/tht/interaction-language.ts new file mode 100644 index 00000000..a91e6d3b --- /dev/null +++ b/backend/src/tht/interaction-language.ts @@ -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; + } +} diff --git a/backend/src/tht/tht-runner.ts b/backend/src/tht/tht-runner.ts index a93624a9..8f5f7851 100644 --- a/backend/src/tht/tht-runner.ts +++ b/backend/src/tht/tht-runner.ts @@ -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(["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. diff --git a/backend/test/e2e-f1.test.ts b/backend/test/e2e-f1.test.ts index cf3df606..8298a24d 100644 --- a/backend/test/e2e-f1.test.ts +++ b/backend/test/e2e-f1.test.ts @@ -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); diff --git a/backend/test/pi-process-manager.test.ts b/backend/test/pi-process-manager.test.ts index 7ceef352..e8e9ae9f 100644 --- a/backend/test/pi-process-manager.test.ts +++ b/backend/test/pi-process-manager.test.ts @@ -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; diff --git a/backend/test/routes-sessions.test.ts b/backend/test/routes-sessions.test.ts index 973211ec..a24f882e 100644 --- a/backend/test/routes-sessions.test.ts +++ b/backend/test/routes-sessions.test.ts @@ -53,7 +53,11 @@ const defaultWorkspaceRegistry = { function buildApp(config: Parameters[0], deps: Record = {}) { 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