mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
- 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.
146 lines
4.9 KiB
TypeScript
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"],
|
|
);
|
|
});
|
|
});
|