feat: pin sessions to workspace revisions
This commit is contained in:
@@ -32,3 +32,29 @@ The unscoped backend suite was also run. The Task 7 code regressions in `test/th
|
|||||||
were fixed; the remaining failures were existing sandbox restrictions on tests that listen on
|
were fixed; the remaining failures were existing sandbox restrictions on tests that listen on
|
||||||
`127.0.0.1` (`listen EPERM: operation not permitted` in SSE/e2e health tests), not application
|
`127.0.0.1` (`listen EPERM: operation not permitted` in SSE/e2e health tests), not application
|
||||||
assertions.
|
assertions.
|
||||||
|
|
||||||
|
## Review fixes — round 1
|
||||||
|
|
||||||
|
- Every new session now resolves `workspaceId` through the registry; an omitted value uses the
|
||||||
|
configured installation default and persists both the resolved ID and revision. Callers cannot
|
||||||
|
bypass revision pinning by supplying a workspace ID.
|
||||||
|
- Browser-local preferences now migrate once from the read-only legacy settings response and hold
|
||||||
|
workspace, provider, model, and thinking. Session creation includes those selections, including
|
||||||
|
direct entry points that run before the composer mounts. The frontend no longer `PUT`s shared
|
||||||
|
settings.
|
||||||
|
- The settings compatibility endpoint honors a stored installation workspace before falling back
|
||||||
|
to the first workspace configuration.
|
||||||
|
- Resume rejects finalized and archived sessions before looking up any pinned snapshot, preserving
|
||||||
|
the read-only response even when a historical snapshot is unavailable.
|
||||||
|
|
||||||
|
### Review verification
|
||||||
|
|
||||||
|
- RED: the added backend tests failed for omitted-default pinning, read-only resume ordering, and
|
||||||
|
stored-default precedence; the added frontend preference tests failed because preferences were
|
||||||
|
neither stored nor included in session requests.
|
||||||
|
- GREEN: `npx vitest run test/tht-runner.test.ts test/routes-sessions.test.ts test/routes-settings.test.ts && npx tsc --noEmit -p .`
|
||||||
|
— 100 tests passed with a clean type check.
|
||||||
|
- GREEN: `npx vitest run && npx tsc -b` — 332 frontend tests passed with a clean type check.
|
||||||
|
- GREEN: `THT_HOME=/private/tmp/thothii-task7-home .venv/bin/pytest tests/test_session_documents.py tests/test_session_mutations.py -q`
|
||||||
|
— 22 tests passed (one existing testcontainers deprecation warning).
|
||||||
|
- `git diff --check` completed cleanly.
|
||||||
|
|||||||
@@ -207,29 +207,34 @@ export function sessionRoutes(
|
|||||||
let s: Settings;
|
let s: Settings;
|
||||||
try { s = await d.getSettings(principal); } catch { return storageFailure(reply); }
|
try { s = await d.getSettings(principal); } catch { return storageFailure(reply); }
|
||||||
const runner = runnerFor(principal);
|
const runner = runnerFor(principal);
|
||||||
let workspaceConfigPath = s.workspace;
|
const requestedWorkspaceId = b.workspaceId ?? s.workspace;
|
||||||
|
if (!requestedWorkspaceId) {
|
||||||
|
return reply.code(409).send({
|
||||||
|
error: WORKSPACE_REVISION_UNAVAILABLE_MESSAGE,
|
||||||
|
code: "workspace_revision_unavailable",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let workspaceConfigPath: string | undefined;
|
||||||
let workspaceId: string | undefined;
|
let workspaceId: string | undefined;
|
||||||
let workspaceRevision: string | undefined;
|
let workspaceRevision: string | undefined;
|
||||||
let allowedModels: readonly string[] | undefined;
|
let allowedModels: readonly string[] | undefined;
|
||||||
if (b.workspaceId) {
|
try {
|
||||||
try {
|
const resolved = await d.workspaceRegistry.read(requestedWorkspaceId);
|
||||||
const resolved = await d.workspaceRegistry.read(b.workspaceId);
|
if (resolved.revision.state !== "operational") {
|
||||||
if (resolved.revision.state !== "operational") {
|
|
||||||
return reply.code(409).send({
|
|
||||||
error: WORKSPACE_REVISION_UNAVAILABLE_MESSAGE,
|
|
||||||
code: "workspace_revision_unavailable",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
workspaceConfigPath = resolved.revision.snapshotPath;
|
|
||||||
workspaceId = resolved.revision.id;
|
|
||||||
workspaceRevision = resolved.revision.commit;
|
|
||||||
allowedModels = resolved.workspace.llm_policy.allowed;
|
|
||||||
} catch {
|
|
||||||
return reply.code(409).send({
|
return reply.code(409).send({
|
||||||
error: WORKSPACE_REVISION_UNAVAILABLE_MESSAGE,
|
error: WORKSPACE_REVISION_UNAVAILABLE_MESSAGE,
|
||||||
code: "workspace_revision_unavailable",
|
code: "workspace_revision_unavailable",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
workspaceConfigPath = resolved.revision.snapshotPath;
|
||||||
|
workspaceId = resolved.revision.id;
|
||||||
|
workspaceRevision = resolved.revision.commit;
|
||||||
|
allowedModels = resolved.workspace.llm_policy.allowed;
|
||||||
|
} catch {
|
||||||
|
return reply.code(409).send({
|
||||||
|
error: WORKSPACE_REVISION_UNAVAILABLE_MESSAGE,
|
||||||
|
code: "workspace_revision_unavailable",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
const provider = b.provider ?? s.provider;
|
const provider = b.provider ?? s.provider;
|
||||||
const model = b.model ?? s.model;
|
const model = b.model ?? s.model;
|
||||||
@@ -378,6 +383,11 @@ export function sessionRoutes(
|
|||||||
} catch { return storageFailure(reply); }
|
} catch { return storageFailure(reply); }
|
||||||
if (!manifest) return reply.code(404).send({ error: "session not found" });
|
if (!manifest) return reply.code(404).send({ error: "session not found" });
|
||||||
const runner = runnerFor(principal);
|
const runner = runnerFor(principal);
|
||||||
|
// Read-only contract FIRST: finalized or archived sessions never attempt compatibility
|
||||||
|
// resolution, even when their historical snapshot was subsequently pruned.
|
||||||
|
if (manifest?.status === "finalized" || manifest?.archived) {
|
||||||
|
return reply.code(409).send({ error: "sessione in sola lettura (finalizzata o archiviata)" });
|
||||||
|
}
|
||||||
const saved = manifest as {
|
const saved = manifest as {
|
||||||
provider?: string; model?: string; thinking?: string;
|
provider?: string; model?: string; thinking?: string;
|
||||||
workspace_id?: string; workspace_revision?: string;
|
workspace_id?: string; workspace_revision?: string;
|
||||||
@@ -397,11 +407,6 @@ export function sessionRoutes(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Read-only contract FIRST: a finalized/archived session must refuse resume even
|
|
||||||
// when a lingering runtime still looks active — the manifest is the truth.
|
|
||||||
if (manifest?.status === "finalized" || manifest?.archived) {
|
|
||||||
return reply.code(409).send({ error: "sessione in sola lettura (finalizzata o archiviata)" });
|
|
||||||
}
|
|
||||||
// This check belongs inside the per-session lock: a preceding cold Resume may have
|
// This check belongs inside the per-session lock: a preceding cold Resume may have
|
||||||
// installed a running runtime while this request was waiting.
|
// installed a running runtime while this request was waiting.
|
||||||
const existing = d.mgr.get(id);
|
const existing = d.mgr.get(id);
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import type { PrincipalContext } from "../auth/principal.js";
|
|||||||
export function effectiveSettings(cfg: AppConfig, stored: Settings): Settings {
|
export function effectiveSettings(cfg: AppConfig, stored: Settings): Settings {
|
||||||
const workspaces = listWorkspaces(cfg.harnessDir);
|
const workspaces = listWorkspaces(cfg.harnessDir);
|
||||||
return {
|
return {
|
||||||
workspace: workspaces[0]?.name ?? stored.workspace,
|
workspace: stored.workspace ?? workspaces[0]?.name,
|
||||||
provider: cfg.defaults.provider ?? stored.provider,
|
provider: cfg.defaults.provider ?? stored.provider,
|
||||||
model: cfg.defaults.model ?? stored.model,
|
model: cfg.defaults.model ?? stored.model,
|
||||||
thinking: cfg.defaults.thinking ?? stored.thinking,
|
thinking: cfg.defaults.thinking ?? stored.thinking,
|
||||||
|
|||||||
@@ -3,13 +3,31 @@ import { spawn as nodeSpawn } from "node:child_process";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import { chmodSync, unlinkSync, writeFileSync } from "node:fs";
|
import { chmodSync, unlinkSync, writeFileSync } from "node:fs";
|
||||||
import { buildApp } from "../src/app.js";
|
import { buildApp as buildRealApp } from "../src/app.js";
|
||||||
import { loadConfig } from "../src/config.js";
|
import { loadConfig } from "../src/config.js";
|
||||||
import { SseHub } from "../src/sse/sse-hub.js";
|
import { SseHub } from "../src/sse/sse-hub.js";
|
||||||
|
|
||||||
const FAKE = path.resolve("../harness/tests/fake_pi/fake_pi_rpc.mjs");
|
const FAKE = path.resolve("../harness/tests/fake_pi/fake_pi_rpc.mjs");
|
||||||
const SCRIPT = path.resolve("../harness/tests/fake_pi/scripts/f1_disambiguation.json");
|
const SCRIPT = path.resolve("../harness/tests/fake_pi/scripts/f1_disambiguation.json");
|
||||||
|
|
||||||
|
const defaultWorkspaceRegistry = {
|
||||||
|
read: vi.fn(async (id: string) => ({
|
||||||
|
workspace: {
|
||||||
|
llm_policy: {
|
||||||
|
allowed: ["zai/glm-5.2", "deepseek/deepseek-v4-pro", "local-qwen/qwen3.6-35b-a3b"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
revision: {
|
||||||
|
id, commit: "e".repeat(40), blob: "f".repeat(40),
|
||||||
|
snapshotPath: `/data/workspace-registry/snapshots/${"e".repeat(40)}/${id}.yaml`, state: "operational",
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
|
function buildApp(config: Parameters<typeof buildRealApp>[0], deps: Record<string, unknown> = {}) {
|
||||||
|
return buildRealApp(config, { workspaceRegistry: defaultWorkspaceRegistry as any, ...deps } as any);
|
||||||
|
}
|
||||||
|
|
||||||
function mutApp(thtRunner: any) {
|
function mutApp(thtRunner: any) {
|
||||||
return buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), {
|
return buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), {
|
||||||
thtRunner: { ollamaEnsure: async () => ({ ok: true }), ...thtRunner },
|
thtRunner: { ollamaEnsure: async () => ({ ok: true }), ...thtRunner },
|
||||||
@@ -186,6 +204,35 @@ test("creates a session from the active immutable workspace revision", async ()
|
|||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("creates a session from the configured default workspace revision when workspaceId is omitted", async () => {
|
||||||
|
const sessionNew = vi.fn(async () => ({ id: "default-pinned" }));
|
||||||
|
const registry = {
|
||||||
|
read: vi.fn(async (id: string) => ({
|
||||||
|
workspace: { llm_policy: { allowed: ["zai/glm-5.2"] } },
|
||||||
|
revision: {
|
||||||
|
id, commit: "c".repeat(40), blob: "d".repeat(40),
|
||||||
|
snapshotPath: `/data/workspace-registry/snapshots/${"c".repeat(40)}/${id}.yaml`, state: "operational",
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), {
|
||||||
|
thtRunner: { sessionNew, searchPack: async () => {} } as any,
|
||||||
|
readiness: { ensure: async () => ({ ok: true }) } as any,
|
||||||
|
mgr: { get: () => undefined, createFor: () => ({ bridge: { onClientEvent: () => {} } }), configure: async () => {}, start: () => {} } as any,
|
||||||
|
getSettings: () => ({ workspace: "psd-clinical", provider: "zai", model: "glm-5.2", thinking: "low" }) as any,
|
||||||
|
listModels: async () => [{ provider: "zai", id: "glm-5.2", name: "GLM 5.2", reasoning: true }],
|
||||||
|
workspaceRegistry: registry as any,
|
||||||
|
});
|
||||||
|
|
||||||
|
await app.inject({ method: "POST", url: "/sessions", payload: { question: "q" } });
|
||||||
|
|
||||||
|
expect(registry.read).toHaveBeenCalledWith("psd-clinical");
|
||||||
|
expect(sessionNew).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
workspaceId: "psd-clinical", workspaceRevision: "c".repeat(40),
|
||||||
|
workspaceConfigPath: `/data/workspace-registry/snapshots/${"c".repeat(40)}/psd-clinical.yaml`,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
test("POST /sessions usa i settings (workspace/provider/model/thinking) e crea+avvia", async () => {
|
test("POST /sessions usa i settings (workspace/provider/model/thinking) e crea+avvia", async () => {
|
||||||
const modelKey = path.join(os.tmpdir(), `thoth-model-key-${process.pid}`);
|
const modelKey = path.join(os.tmpdir(), `thoth-model-key-${process.pid}`);
|
||||||
writeFileSync(modelKey, "test-model-key", { mode: 0o600 });
|
writeFileSync(modelKey, "test-model-key", { mode: 0o600 });
|
||||||
@@ -209,7 +256,7 @@ test("POST /sessions usa i settings (workspace/provider/model/thinking) e crea+a
|
|||||||
});
|
});
|
||||||
const created = await app.inject({ method: "POST", url: "/sessions", payload: { question: "q" } });
|
const created = await app.inject({ method: "POST", url: "/sessions", payload: { question: "q" } });
|
||||||
expect(created.json()).toEqual({ id: "s1" });
|
expect(created.json()).toEqual({ id: "s1" });
|
||||||
expect(sessionNewArg.workspaceConfigPath).toBe("w");
|
expect(sessionNewArg.workspaceConfigPath).toContain(`/snapshots/${"e".repeat(40)}/w.yaml`);
|
||||||
expect(sessionNewArg.provider).toBe("zai");
|
expect(sessionNewArg.provider).toBe("zai");
|
||||||
expect(sessionNewArg.model).toBe("glm-5.2");
|
expect(sessionNewArg.model).toBe("glm-5.2");
|
||||||
expect(sessionNewArg.thinking).toBe("high");
|
expect(sessionNewArg.thinking).toBe("high");
|
||||||
@@ -465,6 +512,25 @@ test("POST /sessions/:id/resume returns a sanitized error when its retained revi
|
|||||||
expect(response.json()).toMatchObject({ code: "workspace_revision_unavailable" });
|
expect(response.json()).toMatchObject({ code: "workspace_revision_unavailable" });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("POST /sessions/:id/resume refuses a pinned finalized session before reading its snapshot", async () => {
|
||||||
|
const readPinned = vi.fn(async () => { throw new Error("must not resolve"); });
|
||||||
|
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), {
|
||||||
|
thtRunner: {
|
||||||
|
sessionShow: async () => ({
|
||||||
|
status: "finalized", archived: false, workspace_id: "psd-clinical", workspace_revision: "a".repeat(40),
|
||||||
|
}),
|
||||||
|
} as any,
|
||||||
|
getSettings: () => ({ workspace: "legacy" }) as any,
|
||||||
|
workspaceRegistry: { readPinned } as any,
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await app.inject({ method: "POST", url: "/sessions/pinned-final/resume" });
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(409);
|
||||||
|
expect(response.json()).toMatchObject({ error: expect.stringMatching(/sola lettura/i) });
|
||||||
|
expect(readPinned).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
test("GET /sessions/:id warns when a legacy manifest has no workspace revision", async () => {
|
test("GET /sessions/:id warns when a legacy manifest has no workspace revision", async () => {
|
||||||
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), {
|
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), {
|
||||||
mgr: {
|
mgr: {
|
||||||
@@ -1801,7 +1867,7 @@ test("POST /sessions proceeds when ollamaEnsure succeeds", async () => {
|
|||||||
});
|
});
|
||||||
const res = await app.inject({ method: "POST", url: "/sessions", payload: { question: "q" } });
|
const res = await app.inject({ method: "POST", url: "/sessions", payload: { question: "q" } });
|
||||||
expect(res.json()).toEqual({ id: "s1" });
|
expect(res.json()).toEqual({ id: "s1" });
|
||||||
expect(ensureWs).toBe("psd");
|
expect(ensureWs).toContain(`/snapshots/${"e".repeat(40)}/psd.yaml`);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("POST /sessions rejects an unavailable saved model before persisting a session", async () => {
|
test("POST /sessions rejects an unavailable saved model before persisting a session", async () => {
|
||||||
@@ -1842,7 +1908,7 @@ test("POST /sessions marks a persisted session failed when runtime construction
|
|||||||
sessionNew: async () => ({ id: "s-runtime-failure" }),
|
sessionNew: async () => ({ id: "s-runtime-failure" }),
|
||||||
failSession: async (id: string, workspace: string) => {
|
failSession: async (id: string, workspace: string) => {
|
||||||
expect(id).toBe("s-runtime-failure");
|
expect(id).toBe("s-runtime-failure");
|
||||||
expect(workspace).toBe("psd");
|
expect(workspace).toContain(`/snapshots/${"e".repeat(40)}/psd.yaml`);
|
||||||
failed += 1;
|
failed += 1;
|
||||||
},
|
},
|
||||||
} as any,
|
} as any,
|
||||||
|
|||||||
@@ -31,6 +31,19 @@ test("GET /settings returns effective defaults (env provider/model/thinking, fir
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("GET /settings uses the stored installation workspace default before the harness fallback", async () => {
|
||||||
|
const { app, dir } = appWithTmpSettings({});
|
||||||
|
try {
|
||||||
|
writeFileSync(join(dir, "settings.json"), JSON.stringify({ workspace: "psd-clinical" }));
|
||||||
|
|
||||||
|
const response = await app.inject({ method: "GET", url: "/settings" });
|
||||||
|
|
||||||
|
expect(response.json()).toMatchObject({ workspace: "psd-clinical" });
|
||||||
|
} finally {
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test("PUT /settings does not persist personal workspace or LLM choices", async () => {
|
test("PUT /settings does not persist personal workspace or LLM choices", async () => {
|
||||||
const { app, dir } = appWithTmpSettings({ PI_PROVIDER: "zai", PI_MODEL: "glm-5.2", PI_THINKING: "medium" }, {
|
const { app, dir } = appWithTmpSettings({ PI_PROVIDER: "zai", PI_MODEL: "glm-5.2", PI_THINKING: "medium" }, {
|
||||||
listModels: async () => [{ provider: "zai", id: "glm-5.2", name: "GLM 5.2", reasoning: true }],
|
listModels: async () => [{ provider: "zai", id: "glm-5.2", name: "GLM 5.2", reasoning: true }],
|
||||||
|
|||||||
@@ -6,16 +6,25 @@ import {
|
|||||||
deleteSession, getSessionDocuments,
|
deleteSession, getSessionDocuments,
|
||||||
} from "./sessions";
|
} from "./sessions";
|
||||||
|
|
||||||
test("createSession POSTs only {question} and returns the id", async () => {
|
test("createSession migrates legacy selections and POSTs browser preferences", async () => {
|
||||||
|
localStorage.clear();
|
||||||
let body: unknown = null;
|
let body: unknown = null;
|
||||||
server.use(
|
server.use(
|
||||||
|
http.get("http://localhost:8787/settings", () => HttpResponse.json({
|
||||||
|
workspace: "psd-clinical", provider: "zai", model: "glm-5.2", thinking: "low",
|
||||||
|
})),
|
||||||
http.post("http://localhost:8787/sessions", async ({ request }) => {
|
http.post("http://localhost:8787/sessions", async ({ request }) => {
|
||||||
body = await request.json();
|
body = await request.json();
|
||||||
return HttpResponse.json({ id: "s1" });
|
return HttpResponse.json({ id: "s1" });
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
expect(await createSession({ question: "q" })).toEqual({ id: "s1" });
|
expect(await createSession({ question: "q" })).toEqual({ id: "s1" });
|
||||||
expect(body).toEqual({ question: "q" });
|
expect(body).toEqual({
|
||||||
|
question: "q", workspaceId: "psd-clinical", provider: "zai", model: "glm-5.2", thinking: "low",
|
||||||
|
});
|
||||||
|
expect(JSON.parse(localStorage.getItem("thothii.workspace-registry.v1.preferences")!)).toEqual({
|
||||||
|
workspaceId: "psd-clinical", provider: "zai", model: "glm-5.2", thinking: "low",
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test.each([202, 204])("prewarmRuntime accepts a body-less %s response", async (status) => {
|
test.each([202, 204])("prewarmRuntime accepts a body-less %s response", async (status) => {
|
||||||
|
|||||||
@@ -1,10 +1,34 @@
|
|||||||
import { apiFetch } from "./client";
|
import { apiFetch } from "./client";
|
||||||
|
import { getSettings } from "./settings";
|
||||||
|
import { workspacePreferences, type WorkspacePreference } from "../workspaces/preferences";
|
||||||
import type {
|
import type {
|
||||||
Principal, ResumeSessionResult, SessionScope, SessionSummary, SessionDocument, UiResponse,
|
Principal, ResumeSessionResult, SessionScope, SessionSummary, SessionDocument, UiResponse,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
export const createSession = (i: { question: string; name?: string }) =>
|
type NewSessionInput = { question: string; name?: string } & Partial<WorkspacePreference>;
|
||||||
apiFetch<{ id: string }>("/sessions", { method: "POST", body: JSON.stringify(i) });
|
|
||||||
|
async function selectedPreferences(): Promise<WorkspacePreference> {
|
||||||
|
const saved = workspacePreferences.load();
|
||||||
|
if (saved.workspaceId && saved.provider && saved.model && saved.thinking) return saved;
|
||||||
|
|
||||||
|
// A direct new-session entry point can run before the composer has mounted. Seed its
|
||||||
|
// browser-local preferences from the legacy read-only defaults once, then keep them local.
|
||||||
|
const legacy = await getSettings();
|
||||||
|
return workspacePreferences.save({
|
||||||
|
workspaceId: saved.workspaceId ?? legacy.workspace,
|
||||||
|
provider: saved.provider ?? legacy.provider,
|
||||||
|
model: saved.model ?? legacy.model,
|
||||||
|
thinking: saved.thinking ?? legacy.thinking,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createSession(i: NewSessionInput) {
|
||||||
|
const preferences = await selectedPreferences();
|
||||||
|
return apiFetch<{ id: string }>("/sessions", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ ...preferences, ...i }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/** Best-effort warm-up; callers must not await it before showing the composer. */
|
/** Best-effort warm-up; callers must not await it before showing the composer. */
|
||||||
export const prewarmRuntime = () =>
|
export const prewarmRuntime = () =>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ function renderShell() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
FakeEventSource.instances = [];
|
FakeEventSource.instances = [];
|
||||||
(globalThis as any).EventSource = FakeEventSource;
|
(globalThis as any).EventSource = FakeEventSource;
|
||||||
useSessionStore.getState().resetSession();
|
useSessionStore.getState().resetSession();
|
||||||
@@ -121,8 +122,7 @@ test("a DWH-unreachable precheck shows a specific alert and preserves the questi
|
|||||||
expect(FakeEventSource.instances).toHaveLength(0);
|
expect(FakeEventSource.instances).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("model selector shows the three Pi-enabled models and persists the selected provider", async () => {
|
test("model selector shows the three Pi-enabled models and stores the selected provider locally", async () => {
|
||||||
let saved: unknown;
|
|
||||||
server.use(
|
server.use(
|
||||||
http.get("http://localhost:8787/settings", () => HttpResponse.json({
|
http.get("http://localhost:8787/settings", () => HttpResponse.json({
|
||||||
workspace: "default", provider: "zai", model: "glm-5.2", thinking: "low",
|
workspace: "default", provider: "zai", model: "glm-5.2", thinking: "low",
|
||||||
@@ -132,11 +132,6 @@ test("model selector shows the three Pi-enabled models and persists the selected
|
|||||||
{ provider: "deepseek", id: "deepseek-v4-flash", name: "DeepSeek V4 Flash", reasoning: true },
|
{ provider: "deepseek", id: "deepseek-v4-flash", name: "DeepSeek V4 Flash", reasoning: true },
|
||||||
{ provider: "local-qwen", id: "qwen3.6-35b-a3b", name: "Qwen3.6 35B A3B Local", reasoning: false },
|
{ provider: "local-qwen", id: "qwen3.6-35b-a3b", name: "Qwen3.6 35B A3B Local", reasoning: false },
|
||||||
] })),
|
] })),
|
||||||
http.put("http://localhost:8787/settings", async ({ request }) => {
|
|
||||||
const body = await request.json() as Record<string, string>;
|
|
||||||
saved = body;
|
|
||||||
return HttpResponse.json(body);
|
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
renderShell();
|
renderShell();
|
||||||
|
|
||||||
@@ -147,9 +142,10 @@ test("model selector shows the three Pi-enabled models and persists the selected
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
await userEvent.selectOptions(select, "qwen3.6-35b-a3b");
|
await userEvent.selectOptions(select, "qwen3.6-35b-a3b");
|
||||||
await waitFor(() => expect(saved).toMatchObject({
|
await waitFor(() => expect(JSON.parse(localStorage.getItem("thothii.workspace-registry.v1.preferences")!))
|
||||||
provider: "local-qwen", model: "qwen3.6-35b-a3b",
|
.toEqual({
|
||||||
}));
|
workspaceId: "default", provider: "local-qwen", model: "qwen3.6-35b-a3b", thinking: "low",
|
||||||
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -27,9 +27,13 @@ test("the form has only a question field (no workspace/model/provider/thinking)"
|
|||||||
expect(screen.queryByLabelText(/thinking/i)).not.toBeInTheDocument();
|
expect(screen.queryByLabelText(/thinking/i)).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("submitting posts only { question } and calls onCreated", async () => {
|
test("submitting includes browser-local migrated preferences and calls onCreated", async () => {
|
||||||
|
localStorage.clear();
|
||||||
let body: unknown = null;
|
let body: unknown = null;
|
||||||
server.use(
|
server.use(
|
||||||
|
http.get("http://localhost:8787/settings", () => HttpResponse.json({
|
||||||
|
workspace: "default", provider: "zai", model: "glm-5.2", thinking: "low",
|
||||||
|
})),
|
||||||
http.post("http://localhost:8787/sessions", async ({ request }) => {
|
http.post("http://localhost:8787/sessions", async ({ request }) => {
|
||||||
body = await request.json();
|
body = await request.json();
|
||||||
return HttpResponse.json({ id: "s1" });
|
return HttpResponse.json({ id: "s1" });
|
||||||
@@ -40,7 +44,10 @@ test("submitting posts only { question } and calls onCreated", async () => {
|
|||||||
await userEvent.type(await screen.findByLabelText(/question/i), "Quante vendite nel 2025?");
|
await userEvent.type(await screen.findByLabelText(/question/i), "Quante vendite nel 2025?");
|
||||||
await userEvent.click(screen.getByRole("button", { name: /^create$/i }));
|
await userEvent.click(screen.getByRole("button", { name: /^create$/i }));
|
||||||
|
|
||||||
await waitFor(() => expect(body).toEqual({ question: "Quante vendite nel 2025?" }));
|
await waitFor(() => expect(body).toEqual({
|
||||||
|
question: "Quante vendite nel 2025?",
|
||||||
|
workspaceId: "default", provider: "zai", model: "glm-5.2", thinking: "low",
|
||||||
|
}));
|
||||||
await waitFor(() => expect(onCreated).toHaveBeenCalledWith("s1"));
|
await waitFor(() => expect(onCreated).toHaveBeenCalledWith("s1"));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { useSessionStore } from "../store/sessionStore";
|
|||||||
import { ComposerFooter, ContextGauge, SteerInput } from "./SteerInput";
|
import { ComposerFooter, ContextGauge, SteerInput } from "./SteerInput";
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
server.use(
|
server.use(
|
||||||
http.post("http://localhost:8787/sessions/:id/steer", () =>
|
http.post("http://localhost:8787/sessions/:id/steer", () =>
|
||||||
new HttpResponse(null, { status: 204 }),
|
new HttpResponse(null, { status: 204 }),
|
||||||
@@ -15,6 +16,25 @@ beforeEach(() => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("new sessions send the browser-selected workspace, model, provider, and thinking", async () => {
|
||||||
|
let body: unknown;
|
||||||
|
localStorage.setItem("thothii.workspace-registry.v1.preferences", JSON.stringify({
|
||||||
|
workspaceId: "psd-clinical", provider: "zai", model: "glm-5.2", thinking: "high",
|
||||||
|
}));
|
||||||
|
server.use(http.post("http://localhost:8787/sessions", async ({ request }) => {
|
||||||
|
body = await request.json();
|
||||||
|
return HttpResponse.json({ id: "s1" });
|
||||||
|
}));
|
||||||
|
render(<SteerInput sessionId={null} />);
|
||||||
|
|
||||||
|
await userEvent.type(screen.getByRole("textbox", { name: /new question/i }), "q");
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: /send/i }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(body).toEqual({
|
||||||
|
question: "q", workspaceId: "psd-clinical", provider: "zai", model: "glm-5.2", thinking: "high",
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
test("renders a text input and submit button", () => {
|
test("renders a text input and submit button", () => {
|
||||||
render(<SteerInput sessionId="s1" />);
|
render(<SteerInput sessionId="s1" />);
|
||||||
expect(screen.getByRole("textbox")).toBeInTheDocument();
|
expect(screen.getByRole("textbox")).toBeInTheDocument();
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
// frontend/src/shell/SteerInput.tsx
|
// frontend/src/shell/SteerInput.tsx
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { CornerDownLeft } from "lucide-react";
|
import { CornerDownLeft } from "lucide-react";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { postSteer, createSession } from "../api/sessions";
|
import { postSteer, createSession } from "../api/sessions";
|
||||||
import { ApiError } from "../api/client";
|
import { ApiError } from "../api/client";
|
||||||
import { getSettings, putSettings, type Settings } from "../api/settings";
|
import { getSettings } from "../api/settings";
|
||||||
import { listWorkspaces } from "../api/workspaces";
|
import { listWorkspaces } from "../api/workspaces";
|
||||||
import { listModels } from "../api/models";
|
import { listModels } from "../api/models";
|
||||||
import { useSessionStore } from "../store/sessionStore";
|
import { useSessionStore } from "../store/sessionStore";
|
||||||
|
import { workspacePreferences, type WorkspacePreference } from "../workspaces/preferences";
|
||||||
|
|
||||||
const THINKING_LEVELS = ["low", "medium", "high"] as const;
|
const THINKING_LEVELS = ["low", "medium", "high"] as const;
|
||||||
|
|
||||||
@@ -151,25 +152,33 @@ export function SteerInput({
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Status strip beneath the composer. Left: the active workspace selector.
|
* Status strip beneath the composer. Left: the active workspace selector.
|
||||||
* Right: live context usage plus model + thinking-level selectors (these replace
|
* Right: live context usage plus browser-local workspace/model/thinking selectors.
|
||||||
* the old Settings dialog and persist through PUT /settings).
|
|
||||||
*/
|
*/
|
||||||
export function ComposerFooter() {
|
export function ComposerFooter() {
|
||||||
const qc = useQueryClient();
|
|
||||||
const { data: settings } = useQuery({ queryKey: ["settings"], queryFn: getSettings });
|
const { data: settings } = useQuery({ queryKey: ["settings"], queryFn: getSettings });
|
||||||
const { data: workspaces = [] } = useQuery({ queryKey: ["workspaces"], queryFn: listWorkspaces });
|
const { data: workspaces = [] } = useQuery({ queryKey: ["workspaces"], queryFn: listWorkspaces });
|
||||||
const { data: modelsData } = useQuery({ queryKey: ["models"], queryFn: listModels });
|
const { data: modelsData } = useQuery({ queryKey: ["models"], queryFn: listModels });
|
||||||
const models = modelsData?.models ?? [];
|
const models = modelsData?.models ?? [];
|
||||||
const tokenUsage = useSessionStore((state) => state.tokenUsage);
|
const tokenUsage = useSessionStore((state) => state.tokenUsage);
|
||||||
|
const [preferences, setPreferences] = useState<WorkspacePreference>(() => workspacePreferences.load());
|
||||||
|
|
||||||
const workspace = settings?.workspace ?? "";
|
useEffect(() => {
|
||||||
const model = settings?.model ?? "";
|
if (!settings) return;
|
||||||
const thinking = settings?.thinking ?? "medium";
|
setPreferences(workspacePreferences.migrate({
|
||||||
|
workspaceId: settings.workspace,
|
||||||
|
provider: settings.provider,
|
||||||
|
model: settings.model,
|
||||||
|
thinking: settings.thinking,
|
||||||
|
}));
|
||||||
|
}, [settings]);
|
||||||
|
|
||||||
// PUT /settings replaces the whole object, so always send the merged settings.
|
const workspace = preferences.workspaceId ?? settings?.workspace ?? "";
|
||||||
async function update(patch: Partial<Settings>) {
|
const model = preferences.model ?? settings?.model ?? "";
|
||||||
await putSettings({ workspace, provider: settings?.provider, model, thinking, ...patch });
|
const thinking = preferences.thinking ?? settings?.thinking ?? "medium";
|
||||||
qc.invalidateQueries({ queryKey: ["settings"] });
|
|
||||||
|
function update(patch: WorkspacePreference) {
|
||||||
|
const next = workspacePreferences.save({ ...preferences, ...patch });
|
||||||
|
setPreferences(next);
|
||||||
}
|
}
|
||||||
|
|
||||||
function onModelChange(id: string) {
|
function onModelChange(id: string) {
|
||||||
@@ -185,7 +194,7 @@ export function ComposerFooter() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between gap-2 border-t border-border/60 px-3 py-1.5">
|
<div className="flex items-center justify-between gap-2 border-t border-border/60 px-3 py-1.5">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<FooterSelect aria-label="Workspace" value={workspace} onChange={(v) => update({ workspace: v })}>
|
<FooterSelect aria-label="Workspace" value={workspace} onChange={(v) => update({ workspaceId: v })}>
|
||||||
{workspaces.length === 0 ? (
|
{workspaces.length === 0 ? (
|
||||||
<option value="">— workspace —</option>
|
<option value="">— workspace —</option>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { App } from "../App";
|
|||||||
import { useSessionStore } from "../store/sessionStore";
|
import { useSessionStore } from "../store/sessionStore";
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
FakeEventSource.instances = [];
|
FakeEventSource.instances = [];
|
||||||
(globalThis as any).EventSource = FakeEventSource;
|
(globalThis as any).EventSource = FakeEventSource;
|
||||||
useSessionStore.getState().resetSession();
|
useSessionStore.getState().resetSession();
|
||||||
@@ -19,6 +20,9 @@ test("F1: create session -> widget via SSE -> respond -> POST /response", async
|
|||||||
server.use(
|
server.use(
|
||||||
http.post("http://localhost:8787/sessions", () => HttpResponse.json({ id: "s1" })),
|
http.post("http://localhost:8787/sessions", () => HttpResponse.json({ id: "s1" })),
|
||||||
http.get("http://localhost:8787/sessions", () => HttpResponse.json([])),
|
http.get("http://localhost:8787/sessions", () => HttpResponse.json([])),
|
||||||
|
http.get("http://localhost:8787/settings", () => HttpResponse.json({
|
||||||
|
workspace: "default", provider: "zai", model: "glm-5.2", thinking: "low",
|
||||||
|
})),
|
||||||
http.get("http://localhost:8787/workspaces", () =>
|
http.get("http://localhost:8787/workspaces", () =>
|
||||||
HttpResponse.json([{ name: "default", file: "default.db" }]),
|
HttpResponse.json([{ name: "default", file: "default.db" }]),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { expect, test } from "vitest";
|
||||||
|
import { workspacePreferences } from "./preferences";
|
||||||
|
|
||||||
|
test("migrates legacy selections once and keeps later browser choices", () => {
|
||||||
|
localStorage.clear();
|
||||||
|
|
||||||
|
expect(workspacePreferences.migrate({
|
||||||
|
workspaceId: "psd-clinical", provider: "zai", model: "glm-5.2", thinking: "medium",
|
||||||
|
})).toMatchObject({ workspaceId: "psd-clinical", model: "glm-5.2" });
|
||||||
|
workspacePreferences.save({ workspaceId: "research", provider: "deepseek", model: "deepseek-v4-pro", thinking: "high" });
|
||||||
|
|
||||||
|
expect(workspacePreferences.migrate({ workspaceId: "legacy" })).toMatchObject({
|
||||||
|
workspaceId: "research", provider: "deepseek", model: "deepseek-v4-pro", thinking: "high",
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
export interface WorkspacePreference {
|
||||||
|
workspaceId?: string;
|
||||||
|
provider?: string;
|
||||||
|
model?: string;
|
||||||
|
thinking?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STORAGE_KEY = "thothii.workspace-registry.v1.preferences";
|
||||||
|
|
||||||
|
function storage(): Storage | undefined {
|
||||||
|
try { return window.localStorage; } catch { return undefined; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function parse(raw: string | null): WorkspacePreference | undefined {
|
||||||
|
if (raw === null) return undefined;
|
||||||
|
try {
|
||||||
|
const value = JSON.parse(raw);
|
||||||
|
return value && typeof value === "object" && !Array.isArray(value) ? value as WorkspacePreference : undefined;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Anonymous, non-secret browser choices for the workspace session composer. */
|
||||||
|
export const workspacePreferences = {
|
||||||
|
load(): WorkspacePreference {
|
||||||
|
return parse(storage()?.getItem(STORAGE_KEY) ?? null) ?? {};
|
||||||
|
},
|
||||||
|
|
||||||
|
save(value: WorkspacePreference): WorkspacePreference {
|
||||||
|
try { storage()?.setItem(STORAGE_KEY, JSON.stringify(value)); } catch { /* storage is optional */ }
|
||||||
|
return value;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Copy the pre-local-storage server values only when this browser has no saved choice. */
|
||||||
|
migrate(legacy: WorkspacePreference): WorkspacePreference {
|
||||||
|
const current = storage()?.getItem(STORAGE_KEY);
|
||||||
|
return current === null || current === undefined ? this.save(legacy) : this.load();
|
||||||
|
},
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user