pi-mono/apps/p2/test/p2/session-start.test.ts
Maximilian Blazek ccff9876e7 feat(p2): a new session's first message sends at once; one composer for every screen
- New session: Send clears the composer and shows the message at once, online or not
  (plugins/chat/session-start.ts); the session is created when connected and its
  messages move to the main conversation's outbox. Refused creations go back to the
  composer; connection failures are retried.
- One composer: Main keeps the chat frame (.chat and its dock) while screens change;
  screens publish what the dock shows (useChatScreen) and portal their transcript into
  it. The composer keeps its focus and the phone's keyboard when the session opens.
- The composer stays in place: the new-session screen shows the session's empty
  context bar, and a message sent to an idle conversation stays a message until its
  entry shows instead of flashing as a queued row.
- Slots start with the registry's known table (knownRegistry), so a screen inside
  slot wrappers mounts once instead of three times.
- A message sent before a conversation has loaded shows alone until it loads.
2026-11-05 12:52:52 +00:00

146 lines
4.9 KiB
TypeScript

// A new session's first messages (plugins/chat/session-start.ts): shown before the session exists, the session created
// once a creator is connected, the messages handed to its main conversation's outbox, and put back into the composer
// when the server refuses the creation.
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { type Outgoing, outboxOf } from "../../plugins/chat/outbox.ts";
import { type Creator, SessionStart } from "../../plugins/chat/session-start.ts";
const message = (id: string): Outgoing => ({
id,
text: `text ${id}`,
files: [],
whenBusy: "followUp",
item: { type: "user", key: `outgoing-${id}`, text: `text ${id}`, images: [], files: [] },
});
const place = {
location: { env: null, cwd: "/tmp" },
choice: { model: { provider: "faux", modelId: "faux-1" }, thinkingLevel: "off" },
} as const;
const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
const ticks = async (count: number) => {
for (let index = 0; index < count; index++) await tick();
};
/** A creator recording what it did; `fail` decides per call what goes wrong, `gate` holds a creation until released. */
function creator(fail: (call: number) => "refuse" | "drop" | undefined = () => undefined) {
const created: string[] = [];
const restored: { ids: string[]; error: string }[] = [];
let calls = 0;
let gate: Promise<void> | undefined;
const value: Creator = {
create: async () => {
const call = ++calls;
await gate;
const outcome = fail(call);
if (outcome === "refuse") throw new Error("refused");
if (outcome === "drop") throw new Error("connection lost");
return `session-${crypto.randomUUID()}`;
},
refused: (error) => error instanceof Error && error.message === "refused",
created: (session) => created.push(session),
restore: (messages, error) =>
restored.push({ ids: messages.map((entry) => entry.id), error: (error as Error).message }),
};
return {
value,
created,
restored,
calls: () => calls,
hold: () => {
const { promise, resolve } = Promise.withResolvers<void>();
gate = promise;
return () => {
gate = undefined;
resolve();
};
},
};
}
describe("session start", () => {
it("shows messages before a creator connects, then creates the session once and hands them to its outbox", async () => {
const start = new SessionStart({ retryDelay: () => 1 });
start.add(message("a"), place);
await tick();
assert.deepEqual(
start.snapshot()?.messages.map((entry) => entry.id),
["a"],
);
const { value, created, calls } = creator();
start.connect(value);
await ticks(5);
assert.equal(calls(), 1);
assert.equal(created.length, 1);
assert.equal(start.snapshot(), undefined);
assert.deepEqual(
outboxOf(`${created[0]}#1`)
.snapshot()
.map((entry) => entry.id),
["a"],
);
});
it("keeps the first message's place; messages sent while creating join the same session", async () => {
const start = new SessionStart({ retryDelay: () => 1 });
const { value, created, calls, hold } = creator();
const release = hold();
start.connect(value);
start.add(message("a"), place);
start.add(message("b"), { ...place, location: { env: null, cwd: "/elsewhere" } });
await ticks(3);
assert.equal(start.snapshot()?.location.cwd, "/tmp");
release();
await ticks(5);
assert.equal(calls(), 1);
assert.deepEqual(
outboxOf(`${created[0]}#1`)
.snapshot()
.map((entry) => entry.id),
["a", "b"],
);
});
it("puts the messages back when the server refuses the creation", async () => {
const start = new SessionStart({ retryDelay: () => 1 });
const { value, created, restored } = creator(() => "refuse");
start.add(message("a"), place);
start.add(message("b"), place);
start.connect(value);
await ticks(5);
assert.deepEqual(created, []);
assert.deepEqual(restored, [{ ids: ["a", "b"], error: "refused" }]);
assert.equal(start.snapshot(), undefined);
});
it("tries a lost connection again, and gives up after six failures while connected", async () => {
const start = new SessionStart({ retryDelay: () => 1 });
const flaky = creator((call) => (call === 1 ? "drop" : undefined));
start.add(message("a"), place);
start.connect(flaky.value);
await new Promise((resolve) => setTimeout(resolve, 20));
assert.equal(flaky.calls(), 2);
assert.equal(flaky.created.length, 1);
const broken = creator(() => "drop");
start.connect(broken.value);
start.add(message("b"), place);
await new Promise((resolve) => setTimeout(resolve, 50));
assert.equal(broken.calls(), 6);
assert.deepEqual(broken.restored, [{ ids: ["b"], error: "connection lost" }]);
});
it("waits while disconnected", async () => {
const start = new SessionStart({ retryDelay: () => 1 });
const { value, calls } = creator();
start.connect(value);
start.connect(undefined);
start.add(message("a"), place);
await ticks(5);
assert.equal(calls(), 0);
assert.deepEqual(
start.snapshot()?.messages.map((entry) => entry.id),
["a"],
);
});
});