unsloth/studio/frontend/tests/project-source-plan.test.ts
Nilay 731ea4cd73
Studio: save a chat or reply to project sources (#8588)
* Studio: save a chat or reply to project sources

* Studio: invalidate the sources probe before and after chat saves

* Studio: resolve the reply's project from its thread before saving

* Studio: refresh an open Sources panel when a source is saved elsewhere

* Studio: refresh the sources panel without resurrecting a deleted row

invalidateProjectSources dispatched the refresh event, and the panel calls it
before each of its own mutations as well as after. handleRemove drops the row
optimistically and then invalidates, so the listener refetched while the DELETE
had not been sent yet and the row came straight back; useRagDocuments has no
request sequencing, so the staler of the two answers can also land last.

Split the two jobs. invalidateProjectSources stays a cache drop.
announceProjectSourcesUpdated drops the cache and tells a mounted list to
refetch, and only the save path calls it, after the upload. The panel's
subscription moves into subscribeProjectSourcesUpdated so the projectId filter
and the teardown are testable without a DOM.

* Studio: name and report a chat saved to project sources

Five things a save got wrong once it left the upload call.

The filename. The backend stores the bytes under a uuid and re-sanitises the
name for its own metadata, so this was never a path safety problem, but it is
what the sources panel lists. Break the Windows device names, which stay
reserved through an extension, drop control characters and trailing dots and
spaces, clamp to a byte budget on a code point boundary so a surrogate pair is
never split, and fall back to chat.md where the backend's own pass would leave
just "_.md".

Success. The toast fired on upload acceptance; indexing is a job, and the panel
hides a document whose job failed, so a failed ingest was silent behind
"Saved to project sources." Poll the job and raise the same "Couldn't index X"
the panel's own upload path raises.

Errors. The upload failure was caught and toasted but never returned, so the
callers could not tell a save that worked from one that did not, and a pair
save could not stop or report a partial failure.

Pair saves. Both halves carried the row's single title, so one click produced
two toasts and two sources with the same name. Name each half after the model
that answered it, and report the count once.

Wording. A reply saved under the chat's own title collided with the whole chat
saved under it; mark the reply apart. And an empty conversation reported
"No messages in this conversation to export." from something that is not an
export.

* Studio: cover saving a chat or reply to project sources

The reply's destination is the one that matters: it comes from the thread being
read, not from the project the sidebar has selected, or a save uploads one
project's chat into another project's sources. thread.tsx is TSX node cannot
load, so that test reads the source the way rag-availability-marker.test.ts
does for the same file; swapping the argument back to activeProjectId fails it.

The rest covers the filename rules, the pair naming, and the save itself:
one toast per click, a failure that says why, a failed ingest that is not left
silent, and a mounted sources list that actually refetches rather than merely
being sent an event.

The toast stub records instead of swallowing, and the authFetch stub can be
given a handler for the one test that needs a response, restored per test so
nothing leaks into the suites that expect no network at all.

* Studio: tell a compare pair's halves apart when they share a model label

The LoRA compare runs one checkpoint with the adapter off and on, so both
threads record the same modelId and the two saved sources came out with
byte-identical titles. The sources panel renders only the filename, and the
backend keys uploads by uuid, so the pair arrived as two indistinguishable
rows. Name a colliding half by its modelType, or by position when that is
absent; two different models keep the plain name.

* Studio: save a reply whole, and name compare halves by pane not arrival

getCopyText joins text parts alone, so saving a reply dropped its reasoning,
tool calls and citations, and a reply that was only a tool call reported as
having no content. Route it through the same conversion the whole-chat save
uses.

listStoredChatThreads sorts by updatedAt, so the positional fallback numbered
the halves by whichever answered last: saving one pair twice could swap the two
names. modelType already carries the pane, so name from that and keep position
as a last resort only.

---------

Co-authored-by: danielhanchen <danielhanchen@gmail.com>
2026-08-14 06:35:04 -07:00

119 lines
4.5 KiB
TypeScript

// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import assert from "node:assert/strict";
import test from "node:test";
import { planChatItemSources } from "../src/features/chat/utils/project-source-plan.ts";
test("a single chat is saved once, under its own title", () => {
assert.deepEqual(
planChatItemSources({ id: "t1", title: "Fix my regex", type: "single" }, []),
[{ id: "t1", title: "Fix my regex" }],
);
});
test("each half of a pair is named after the model that answered", () => {
assert.deepEqual(
planChatItemSources({ id: "p1", title: "Fix my regex", type: "pair" }, [
{ id: "t1", modelId: "unsloth/Qwen3-8B-GGUF:Q4_K_M" },
{ id: "t2", modelId: "openai/gpt-5" },
]),
[
{ id: "t1", title: "Fix my regex - Qwen3-8B-GGUF" },
{ id: "t2", title: "Fix my regex - gpt-5" },
],
);
});
test("an unnamed model falls back to its position, so the two never collide", () => {
const plans = planChatItemSources(
{ id: "p1", title: "Fix my regex", type: "pair" },
[{ id: "t1" }, { id: "t2", modelId: " " }],
);
assert.deepEqual(
plans.map((plan) => plan.title),
["Fix my regex - 1", "Fix my regex - 2"],
);
assert.equal(new Set(plans.map((plan) => plan.title)).size, 2);
});
test("a LoRA compare names the adapter halves apart, not twice the same", () => {
// The base/lora compare toggles the adapter on one loaded checkpoint, so both
// threads record the same modelId; the model name alone cannot tell them apart.
const plans = planChatItemSources(
{ id: "p1", title: "Fix my regex", type: "pair" },
[
{ id: "t1", modelId: "unsloth/Qwen3-8B", modelType: "base" },
{ id: "t2", modelId: "unsloth/Qwen3-8B", modelType: "lora" },
],
);
assert.deepEqual(plans, [
{ id: "t1", title: "Fix my regex - Qwen3-8B - base" },
{ id: "t2", title: "Fix my regex - Qwen3-8B - fine-tuned" },
]);
});
test("two panes on the same checkpoint fall back to their position", () => {
// Same repo, different quant: the variant is not part of modelId, and the
// colon suffix is stripped anyway, so both labels read "Qwen3-8B-GGUF".
const plans = planChatItemSources(
{ id: "p1", title: "Fix my regex", type: "pair" },
[
{ id: "t1", modelId: "unsloth/Qwen3-8B-GGUF:Q4_K_M", modelType: "model1" },
{ id: "t2", modelId: "unsloth/Qwen3-8B-GGUF:Q8_0", modelType: "model2" },
],
);
assert.deepEqual(
plans.map((plan) => plan.title),
["Fix my regex - Qwen3-8B-GGUF - 1", "Fix my regex - Qwen3-8B-GGUF - 2"],
);
assert.equal(new Set(plans.map((plan) => plan.title)).size, 2);
});
test("a pane keeps its number whichever half answered last", () => {
// listStoredChatThreads sorts by updatedAt, so the pane that finished last
// arrives first. Numbering by arrival would label model2's source "- 1" and
// swap the two names between saves of the same pair.
const panes = [
{ id: "t1", modelId: "unsloth/Qwen3-8B-GGUF:Q4_K_M", modelType: "model1" },
{ id: "t2", modelId: "unsloth/Qwen3-8B-GGUF:Q8_0", modelType: "model2" },
];
const item = { id: "p1", title: "Fix my regex", type: "pair" };
const byId = (plans: { id: string; title: string }[]) =>
Object.fromEntries(plans.map((plan) => [plan.id, plan.title]));
assert.deepEqual(byId(planChatItemSources(item, [...panes].reverse())), {
t1: "Fix my regex - Qwen3-8B-GGUF - 1",
t2: "Fix my regex - Qwen3-8B-GGUF - 2",
});
assert.deepEqual(
byId(planChatItemSources(item, panes)),
byId(planChatItemSources(item, [...panes].reverse())),
);
});
test("a LoRA pair keeps its naming when the model name is what collides", () => {
const halves = [
{ id: "t1", modelType: "base" },
{ id: "t2", modelType: "lora" },
];
const item = { id: "p1", title: "Fix my regex", type: "pair" };
// No modelId at all: the label itself falls back to the pane, which must not
// move with arrival order either.
assert.deepEqual(planChatItemSources(item, [...halves].reverse()), [
{ id: "t2", title: "Fix my regex - fine-tuned" },
{ id: "t1", title: "Fix my regex - base" },
]);
});
test("a pair with one surviving half keeps the plain title", () => {
assert.deepEqual(
planChatItemSources({ id: "p1", title: "Fix my regex", type: "pair" }, [
{ id: "t1", modelId: "unsloth/Qwen3-8B" },
]),
[{ id: "t1", title: "Fix my regex" }],
);
assert.deepEqual(
planChatItemSources({ id: "p1", title: "Fix my regex", type: "pair" }, []),
[],
);
});