Merge PR #154 short opening URLs and recoverable prompt handoff
This commit is contained in:
@@ -0,0 +1,203 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import { SessionModel } from "../components/session-core.ts";
|
||||
import { newUserMessage } from "../src/core/native-messages.ts";
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason: unknown) => void;
|
||||
const promise = new Promise<T>((done, fail) => { resolve = done; reject = fail; });
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
describe("new-session opening delivery acceptance", () => {
|
||||
it("notifies acceptance before the record read or teacher stream completes", async () => {
|
||||
const record = deferred<Response>();
|
||||
const accepted = deferred<void>();
|
||||
const calls: string[] = [];
|
||||
const m = new SessionModel({ chatId: "c1", initialMessages: [], fetch: async url => {
|
||||
calls.push(String(url));
|
||||
if (String(url) === "/api/chat") return Response.json({ ok: true });
|
||||
if (String(url).endsWith("/stream")) return new Response("data: [DONE]\n\n");
|
||||
return record.promise;
|
||||
} });
|
||||
let completed = false;
|
||||
const sending = m.send({ type: "text", text: "my syllabus" }, () => accepted.resolve()).then(result => { completed = true; return result; });
|
||||
await accepted.promise;
|
||||
assert.equal(completed, false);
|
||||
assert.equal(m.getState().hasConfirmedConversation, false, "optimistic user text is not canonical proof");
|
||||
record.resolve(Response.json({ messages: [newUserMessage("my syllabus")] }));
|
||||
assert.equal(await sending, true);
|
||||
assert.equal(m.getState().hasConfirmedConversation, true);
|
||||
assert.equal(calls.filter(url => url === "/api/chat").length, 1);
|
||||
});
|
||||
|
||||
it("a teardown racing the POST still cleans up on acknowledgment without attaching", async () => {
|
||||
const post = deferred<Response>();
|
||||
let notified = 0;
|
||||
const m = new SessionModel({ chatId: "c1", initialMessages: [], fetch: async url => {
|
||||
assert.equal(String(url), "/api/chat");
|
||||
return post.promise;
|
||||
} });
|
||||
const sending = m.send({ type: "text", text: "hello" }, () => { notified++; });
|
||||
m.stop();
|
||||
post.resolve(Response.json({ ok: true }));
|
||||
assert.equal(await sending, true);
|
||||
assert.equal(notified, 1);
|
||||
});
|
||||
|
||||
for (const failure of ["refusal", "409", "lost acknowledgment"] as const) {
|
||||
it(`${failure} does not notify acceptance or leave a false confirmed conversation`, async () => {
|
||||
let notified = false;
|
||||
const m = new SessionModel({ chatId: "c1", initialMessages: [], waitMaxMs: 0, fetch: async () => {
|
||||
if (failure === "lost acknowledgment") throw new TypeError("connection lost");
|
||||
return new Response("refused", { status: failure === "409" ? 409 : 503 });
|
||||
} });
|
||||
assert.equal(await m.send({ type: "text", text: "keep this" }, () => { notified = true; }), false);
|
||||
assert.equal(notified, false);
|
||||
assert.equal(m.getState().hasConfirmedConversation, false);
|
||||
assert.equal(m.getState().nativeMessages.length, 0, "restore the opening draft, not a stranded optimistic bubble");
|
||||
assert.equal(m.status, "error");
|
||||
});
|
||||
}
|
||||
|
||||
for (const failure of ["network", "503"] as const) {
|
||||
it(`a teardown racing an opening ${failure} failure restores the bubble/error recovery state`, async () => {
|
||||
const post = deferred<Response>();
|
||||
let notified = 0;
|
||||
const envelope = JSON.stringify({ code: "models_not_configured", missing: ["strong"] });
|
||||
const m = new SessionModel({ chatId: "c1", initialMessages: [], fetch: async url => {
|
||||
assert.equal(String(url), "/api/chat", "failure must not attach or resend");
|
||||
return post.promise;
|
||||
} });
|
||||
const sending = m.send({ type: "text", text: "recover this opening" }, () => { notified++; });
|
||||
m.stop(); // StrictMode's cleanup detaches the consume, NOT this POST.
|
||||
if (failure === "network") post.reject(new TypeError("opening connection lost"));
|
||||
else post.resolve(new Response(envelope, { status: 503 }));
|
||||
assert.equal(await sending, false);
|
||||
assert.equal(notified, 0);
|
||||
assert.equal(m.getState().nativeMessages.length, 0, "no stranded optimistic opening bubble");
|
||||
assert.equal(m.status, "error");
|
||||
assert.equal(m.getState().error?.message, failure === "network" ? "opening connection lost" : envelope,
|
||||
"the raw refusal must reach the existing setup-recovery effect");
|
||||
});
|
||||
|
||||
it(`an older opening ${failure} failure removes only its own bubble while a newer send owns state`, async () => {
|
||||
const first = deferred<Response>();
|
||||
const second = deferred<Response>();
|
||||
let posts = 0;
|
||||
const m = new SessionModel({ chatId: "c1", initialMessages: [], fetch: async url => {
|
||||
assert.equal(String(url), "/api/chat");
|
||||
return ++posts === 1 ? first.promise : second.promise;
|
||||
} });
|
||||
const older = m.send({ type: "text", text: "older opening" }, () => assert.fail("not accepted"));
|
||||
m.stop();
|
||||
const newer = m.send({ type: "text", text: "newer text" });
|
||||
if (failure === "network") first.reject(new TypeError("older lost acknowledgment"));
|
||||
else first.resolve(new Response("older refusal", { status: 503 }));
|
||||
assert.equal(await older, false);
|
||||
assert.equal(m.status, "submitted", "older failure must not settle the newer send");
|
||||
assert.equal(m.getState().error, null);
|
||||
assert.deepEqual(m.getState().nativeMessages.map(message => message.content), ["newer text"]);
|
||||
second.resolve(new Response("newer failure", { status: 503 }));
|
||||
assert.equal(await newer, false);
|
||||
assert.equal(m.getState().error?.message, "newer failure");
|
||||
assert.deepEqual(m.getState().nativeMessages.map(message => message.content), ["newer text"],
|
||||
"ordinary text retains its existing optimistic failure presentation");
|
||||
});
|
||||
}
|
||||
|
||||
it("a newer send remains the owner even after it fails and is stopped", async () => {
|
||||
const first = deferred<Response>();
|
||||
let posts = 0;
|
||||
const m = new SessionModel({ chatId: "c1", initialMessages: [], fetch: async () => {
|
||||
return ++posts === 1 ? first.promise : new Response("newer failure", { status: 503 });
|
||||
} });
|
||||
const older = m.send({ type: "text", text: "older opening" }, () => assert.fail("not accepted"));
|
||||
m.stop();
|
||||
await m.send({ type: "text", text: "newer text" });
|
||||
m.stop();
|
||||
first.reject(new TypeError("older failure"));
|
||||
assert.equal(await older, false);
|
||||
assert.equal(m.status, "ready");
|
||||
assert.equal(m.getState().error?.message, "newer failure");
|
||||
assert.deepEqual(m.getState().nativeMessages.map(message => message.content), ["newer text"]);
|
||||
});
|
||||
|
||||
it("a replacement reattach owns state instead of the older stopped opening POST", async () => {
|
||||
const post = deferred<Response>();
|
||||
const record = deferred<Response>();
|
||||
const saved = newUserMessage("server-owned text");
|
||||
const m = new SessionModel({ chatId: "c1", initialMessages: [], fetch: async url => {
|
||||
if (String(url) === "/api/chat") return post.promise;
|
||||
if (String(url).endsWith("/stream")) return new Response("data: [DONE]\n\n");
|
||||
return record.promise;
|
||||
} });
|
||||
const older = m.send({ type: "text", text: "older opening" }, () => assert.fail("not accepted"));
|
||||
m.stop();
|
||||
const replacement = m.resumeStream();
|
||||
post.reject(new TypeError("older failure"));
|
||||
assert.equal(await older, false);
|
||||
assert.equal(m.status, "submitted");
|
||||
assert.equal(m.getState().error, null);
|
||||
record.resolve(Response.json({ messages: [saved] }));
|
||||
await replacement;
|
||||
assert.equal(m.status, "ready");
|
||||
assert.deepEqual(m.getState().nativeMessages, [saved]);
|
||||
assert.equal(m.getState().error, null);
|
||||
});
|
||||
|
||||
it("a stopped replacement reattach still supersedes the older opening refusal", async () => {
|
||||
const post = deferred<Response>();
|
||||
const record = deferred<Response>();
|
||||
const m = new SessionModel({ chatId: "c1", initialMessages: [], fetch: async url => {
|
||||
return String(url) === "/api/chat" ? post.promise : record.promise;
|
||||
} });
|
||||
const older = m.send({ type: "text", text: "older opening" }, () => assert.fail("not accepted"));
|
||||
m.stop();
|
||||
const replacement = m.resumeStream();
|
||||
m.stop();
|
||||
post.resolve(new Response("older refusal", { status: 503 }));
|
||||
assert.equal(await older, false);
|
||||
assert.equal(m.status, "ready");
|
||||
assert.equal(m.getState().error, null);
|
||||
assert.equal(m.getState().nativeMessages.length, 0);
|
||||
record.resolve(Response.json({ messages: [] }));
|
||||
await replacement;
|
||||
assert.equal(m.status, "ready");
|
||||
assert.equal(m.getState().error, null);
|
||||
});
|
||||
|
||||
it("a stopped ordinary text failure retains the pre-existing teardown behavior", async () => {
|
||||
const post = deferred<Response>();
|
||||
const m = new SessionModel({ chatId: "c1", initialMessages: [], fetch: async () => post.promise });
|
||||
const sending = m.send({ type: "text", text: "ordinary text" });
|
||||
m.stop();
|
||||
post.reject(new TypeError("connection lost"));
|
||||
assert.equal(await sending, false);
|
||||
assert.equal(m.status, "ready");
|
||||
assert.equal(m.getState().error, null);
|
||||
assert.deepEqual(m.getState().nativeMessages.map(message => message.content), ["ordinary text"]);
|
||||
});
|
||||
|
||||
it("post-acceptance attachment failure remains accepted and does not resend input", async () => {
|
||||
let posts = 0;
|
||||
let notified = 0;
|
||||
const m = new SessionModel({ chatId: "c1", initialMessages: [], fetch: async url => {
|
||||
if (String(url) === "/api/chat") { posts++; return Response.json({ ok: true }); }
|
||||
throw new TypeError("record read failed");
|
||||
} });
|
||||
assert.equal(await m.send({ type: "text", text: "hello" }, () => { notified++; }), true);
|
||||
assert.equal(notified, 1);
|
||||
assert.equal(posts, 1);
|
||||
assert.equal(m.status, "error");
|
||||
});
|
||||
|
||||
it("a busy gate sends nothing, then canonical adoption can confirm an earlier lost acknowledgment", async () => {
|
||||
const m = new SessionModel({ chatId: "c1", initialMessages: [], fetch: async () => { throw new Error("must not post"); } });
|
||||
m.readyRef = false;
|
||||
assert.equal(await m.send({ type: "text", text: "hello" }, () => assert.fail("must not acknowledge")), false);
|
||||
m.setMessages([newUserMessage("hello")]);
|
||||
assert.equal(m.getState().hasConfirmedConversation, true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { afterEach, beforeEach, describe, it } from "node:test";
|
||||
import { clearOpeningPrompt, readOpeningPrompt, readOpeningPromptEntry, stashOpeningPrompt } from "../components/opening-prompt.ts";
|
||||
import { SessionStarter } from "../components/start-session.ts";
|
||||
|
||||
const original = Object.getOwnPropertyDescriptor(globalThis, "sessionStorage");
|
||||
let data: Map<string, string>;
|
||||
let storage: Pick<Storage, "getItem" | "setItem" | "removeItem">;
|
||||
function install(value: unknown) {
|
||||
Object.defineProperty(globalThis, "sessionStorage", { configurable: true, value });
|
||||
}
|
||||
beforeEach(() => {
|
||||
data = new Map();
|
||||
storage = {
|
||||
getItem: key => data.get(key) ?? null,
|
||||
setItem: (key, value) => { data.set(key, value); },
|
||||
removeItem: key => { data.delete(key); },
|
||||
};
|
||||
install(storage);
|
||||
});
|
||||
afterEach(() => {
|
||||
if (original) Object.defineProperty(globalThis, "sessionStorage", original);
|
||||
else Reflect.deleteProperty(globalThis, "sessionStorage");
|
||||
});
|
||||
const fail = () => { throw new Error("synthetic storage failure"); };
|
||||
|
||||
describe("opening message storage", () => {
|
||||
it("keeps full text off the URL and records attempted delivery before send", () => {
|
||||
const text = "a syllabus with Unicode 世界 & ? # ".repeat(1500);
|
||||
assert.equal(stashOpeningPrompt("c1", text), true);
|
||||
assert.deepEqual(readOpeningPromptEntry("c1"), { text, attempted: false });
|
||||
assert.equal(stashOpeningPrompt("c1", text, true), true);
|
||||
assert.deepEqual(readOpeningPromptEntry("c1"), { text, attempted: true });
|
||||
assert.equal(clearOpeningPrompt("c1"), true);
|
||||
assert.equal(readOpeningPrompt("c1"), undefined);
|
||||
});
|
||||
|
||||
it("treats a legacy raw-text stash conservatively as a possibly attempted send", () => {
|
||||
data.set("lh.openingPrompt.old", "teach me logic");
|
||||
assert.deepEqual(readOpeningPromptEntry("old"), { text: "teach me logic", attempted: true });
|
||||
});
|
||||
|
||||
for (const unavailable of [undefined, null]) {
|
||||
it(`reports unavailable storage (${unavailable}) instead of claiming a safe handover`, () => {
|
||||
install(unavailable);
|
||||
assert.equal(stashOpeningPrompt("c1", "keep this"), false);
|
||||
assert.equal(readOpeningPrompt("c1"), undefined);
|
||||
assert.equal(clearOpeningPrompt("c1"), false);
|
||||
});
|
||||
}
|
||||
it("catches a throwing storage getter", () => {
|
||||
Object.defineProperty(globalThis, "sessionStorage", { configurable: true, get: fail });
|
||||
assert.equal(stashOpeningPrompt("c1", "keep this"), false);
|
||||
assert.equal(readOpeningPrompt("c1"), undefined);
|
||||
assert.equal(clearOpeningPrompt("c1"), false);
|
||||
});
|
||||
for (const method of ["getItem", "setItem", "removeItem"] as const) {
|
||||
it(`contains ${method} exceptions without crashing a source or chat page`, () => {
|
||||
storage[method] = fail;
|
||||
assert.doesNotThrow(() => stashOpeningPrompt("c1", "keep this"));
|
||||
assert.doesNotThrow(() => readOpeningPrompt("c1"));
|
||||
assert.doesNotThrow(() => clearOpeningPrompt("c1"));
|
||||
if (method !== "removeItem") assert.equal(stashOpeningPrompt("c1", "keep this"), false);
|
||||
});
|
||||
}
|
||||
it("does not accept a storage write that failed silently", () => {
|
||||
storage.setItem = () => {};
|
||||
assert.equal(stashOpeningPrompt("c1", "keep this"), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("new-session create and handover", () => {
|
||||
function createFetch() {
|
||||
const bodies: unknown[] = [];
|
||||
const fetchLike = (async (_url, init) => {
|
||||
bodies.push(JSON.parse(String(init?.body)));
|
||||
return Response.json({ id: `chat_${bodies.length}` });
|
||||
}) as typeof fetch;
|
||||
return { bodies, fetchLike };
|
||||
}
|
||||
it("refuses navigation on quota failure, then reuses the created session for unchanged retry", async () => {
|
||||
const starter = new SessionStarter();
|
||||
const { bodies, fetchLike } = createFetch();
|
||||
const text = "full text ".repeat(4000);
|
||||
const setItem = storage.setItem;
|
||||
storage.setItem = fail;
|
||||
await assert.rejects(starter.start({ title: text }, fetchLike));
|
||||
assert.equal(bodies.length, 1);
|
||||
storage.setItem = setItem;
|
||||
assert.equal(await starter.start({ title: text }, fetchLike), "/chat/chat_1");
|
||||
assert.equal(bodies.length, 1);
|
||||
assert.equal(readOpeningPrompt("chat_1"), text);
|
||||
});
|
||||
it("reuses creation even when verifying the first storage write failed", async () => {
|
||||
const starter = new SessionStarter();
|
||||
const { bodies, fetchLike } = createFetch();
|
||||
const getItem = storage.getItem;
|
||||
storage.getItem = fail;
|
||||
await assert.rejects(starter.start({ title: "hello" }, fetchLike));
|
||||
storage.getItem = getItem;
|
||||
assert.equal(await starter.start({ title: "hello" }, fetchLike), "/chat/chat_1");
|
||||
assert.equal(bodies.length, 1);
|
||||
});
|
||||
it("changed text, mode, picks or birth settings create a matching new session", async () => {
|
||||
const starter = new SessionStarter();
|
||||
const { bodies, fetchLike } = createFetch();
|
||||
await starter.start({ title: "original" }, fetchLike, "model-a");
|
||||
await starter.start({ title: "edited" }, fetchLike, "model-a");
|
||||
await starter.start({ title: "edited", kind: "practice" }, fetchLike, "model-a");
|
||||
const picked = { title: "edited", kind: "practice" as const, graphContext: { propositions: [{ id: "p1", name: "P", statement: "S" }] } };
|
||||
await starter.start(picked, fetchLike, "model-a");
|
||||
await starter.start(picked, fetchLike, "model-b");
|
||||
assert.equal(bodies.length, 5);
|
||||
assert.equal(readOpeningPrompt("chat_1"), undefined);
|
||||
});
|
||||
it("does not stash or navigate on a refused creation or invalid ID", async () => {
|
||||
for (const response of [new Response("unavailable", { status: 503 }), Response.json({}), Response.json({ id: "../bad?prompt=leak" })]) {
|
||||
const starter = new SessionStarter();
|
||||
await assert.rejects(starter.start({ title: "hello" }, (async () => response) as typeof fetch));
|
||||
assert.equal(data.size, 0);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -2,9 +2,24 @@ import React from "react";
|
||||
(globalThis as Record<string, unknown>).React = React;
|
||||
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, it } from "node:test";
|
||||
import { afterEach, beforeEach, describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { DraftShell, startDraftSession } from "../components/new-session.tsx";
|
||||
import { clearOpeningPrompt, readOpeningPrompt, OPENING_PROMPT_FAILURE } from "../components/opening-prompt.ts";
|
||||
|
||||
const originalStorage = Object.getOwnPropertyDescriptor(globalThis, "sessionStorage");
|
||||
beforeEach(() => {
|
||||
const data = new Map<string, string>();
|
||||
Object.defineProperty(globalThis, "sessionStorage", { configurable: true, value: {
|
||||
getItem: (key: string) => data.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => { data.set(key, value); },
|
||||
removeItem: (key: string) => { data.delete(key); },
|
||||
} });
|
||||
});
|
||||
afterEach(() => {
|
||||
if (originalStorage) Object.defineProperty(globalThis, "sessionStorage", originalStorage);
|
||||
else Reflect.deleteProperty(globalThis, "sessionStorage");
|
||||
});
|
||||
|
||||
/**
|
||||
* The sessionless draft view (/chat/new, owner 2026-09-13): "+ New session"
|
||||
@@ -25,10 +40,10 @@ function fakeFetch(id: string) {
|
||||
}
|
||||
|
||||
describe("startDraftSession — the create-on-send contract", () => {
|
||||
it("creates the session titled with the first message, returns the prompt redirect", async () => {
|
||||
it("creates the session titled with the first message and returns a short path", async () => {
|
||||
const { f, calls } = fakeFetch("c_new");
|
||||
const target = await startDraftSession("teach me orbital mechanics", f);
|
||||
assert.equal(target, "/chat/c_new?prompt=teach%20me%20orbital%20mechanics");
|
||||
assert.equal(target, "/chat/c_new");
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(calls[0]!.url, "/api/chats");
|
||||
assert.equal(calls[0]!.method, "POST");
|
||||
@@ -41,6 +56,37 @@ describe("startDraftSession — the create-on-send contract", () => {
|
||||
assert.equal(calls.length, 1, "a single create call — the birth stamp is server-side");
|
||||
});
|
||||
|
||||
it("a long opening text stays out of the URL and is stashed for the chat page", async () => {
|
||||
const mem = new Map<string, string>();
|
||||
const fake = {
|
||||
getItem: (k: string) => mem.get(k) ?? null,
|
||||
setItem: (k: string, v: string) => {
|
||||
mem.set(k, v);
|
||||
},
|
||||
removeItem: (k: string) => {
|
||||
mem.delete(k);
|
||||
},
|
||||
};
|
||||
const holder = globalThis as { sessionStorage?: Storage };
|
||||
const prev = holder.sessionStorage;
|
||||
Object.defineProperty(globalThis, "sessionStorage", { configurable: true, value: fake });
|
||||
try {
|
||||
const { f } = fakeFetch("c_long");
|
||||
const text = `${"B2 First grammar item. ".repeat(1500)}end`;
|
||||
const target = await startDraftSession(text, f);
|
||||
assert.equal(target, "/chat/c_long");
|
||||
assert.equal(target!.includes("?"), false);
|
||||
const stashed = readOpeningPrompt("c_long");
|
||||
assert.equal(stashed?.length, text.length);
|
||||
assert.equal(stashed, text);
|
||||
clearOpeningPrompt("c_long");
|
||||
assert.equal(readOpeningPrompt("c_long"), undefined);
|
||||
} finally {
|
||||
if (prev === undefined) delete holder.sessionStorage;
|
||||
else Object.defineProperty(globalThis, "sessionStorage", { configurable: true, value: prev });
|
||||
}
|
||||
});
|
||||
|
||||
it("empty or whitespace text never creates anything", async () => {
|
||||
const { f, calls } = fakeFetch("never");
|
||||
assert.equal(await startDraftSession("", f), null);
|
||||
@@ -50,6 +96,12 @@ describe("startDraftSession — the create-on-send contract", () => {
|
||||
});
|
||||
|
||||
describe("DraftShell — the sessionless surface", () => {
|
||||
it("storage failure leaves the full draft editable beside the approved error", () => {
|
||||
const html = renderToStaticMarkup(<DraftShell busy={false} text="keep all my text" onChange={() => {}} onSend={() => {}} startError />);
|
||||
assert.ok(html.includes('role="alert"'));
|
||||
assert.ok(html.includes(OPENING_PROMPT_FAILURE));
|
||||
assert.match(html, /<textarea[^>]*>keep all my text<\/textarea>/);
|
||||
});
|
||||
it("renders the composer blob, the stats bar, and the fresh-chat empty state", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<DraftShell
|
||||
@@ -188,7 +240,7 @@ describe("the practice draft (?mode=practice, 2026-10-04)", () => {
|
||||
return new Response(JSON.stringify({ id: "c9" }), { status: 201 });
|
||||
}) as unknown as typeof fetch;
|
||||
const target = await startDraftSession("the parity chain", f, "practice");
|
||||
assert.equal(target, "/chat/c9?prompt=the%20parity%20chain");
|
||||
assert.equal(target, "/chat/c9");
|
||||
assert.deepEqual(body, { title: "the parity chain", kind: "practice" });
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user